diff --git a/bun.lock b/bun.lock index 04b70f0e..e1d6bf94 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@happy-dom/global-registrator": "^20.13.2", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2.10.1", @@ -79,12 +80,20 @@ }, "sdk/ts": { "name": "@codegraff/sdk", - "version": "0.3.1", + "version": "0.4.0", "devDependencies": { "@types/node": "^22.10.2", "bun-types": "^1.3.11", "typescript": "~5.9.3", }, + "optionalDependencies": { + "@codegraff/graff-darwin-arm64": "0.4.0", + "@codegraff/graff-darwin-x64": "0.4.0", + "@codegraff/graff-linux-arm64": "0.4.0", + "@codegraff/graff-linux-x64": "0.4.0", + "@codegraff/graff-win32-arm64": "0.4.0", + "@codegraff/graff-win32-x64": "0.4.0", + }, }, }, "packages": { @@ -198,6 +207,8 @@ "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.9", "", {}, "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.13.2", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.13.2" } }, "sha512-pGO7WhgHVLi7mi7zCLyJ+FkTad2aPTcjUXhRbgA4T5n1vLRJ/9uw3EsLieScNSX6HOfPF947bOP7N42l+KrWjg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -518,6 +529,10 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/type-utils": "8.61.1", "@typescript-eslint/utils": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg=="], @@ -600,6 +615,8 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -818,7 +835,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -960,6 +977,8 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "happy-dom": ["happy-dom@20.13.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-VC9HoEaT3Jf3K/1g/GjMYGAZCCg+ULgGVcXJlAeSzdTj8mIoPJRp14KVclP5gpUcF/kauuaVB2ri2/WxXbl4vg=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1512,6 +1531,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1526,6 +1547,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1558,6 +1581,8 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@happy-dom/global-registrator/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -1574,6 +1599,8 @@ "@ts-morph/common/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@types/ws/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1588,6 +1615,8 @@ "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "buffer-image-size/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + "bun-types/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], "coa/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -1614,12 +1643,16 @@ "dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "happy-dom/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -1674,14 +1707,20 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "buffer-image-size/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "coa/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -1702,6 +1741,8 @@ "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "happy-dom/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "svgo/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], diff --git a/docs/adr/0062-background-servers-idle-lifecycle-and-ownership-record.md b/docs/adr/0062-background-servers-idle-lifecycle-and-ownership-record.md new file mode 100644 index 00000000..e3732ea0 --- /dev/null +++ b/docs/adr/0062-background-servers-idle-lifecycle-and-ownership-record.md @@ -0,0 +1,78 @@ +# 0062. Background servers have an idle lifecycle and an ownership record + +Status: accepted 2026-09-03 + +## Context + +A dev server the model starts with `bash {run_in_background: true}` (or one +that auto-backgrounded after the foreground wait, ADR 0026) keeps running +after the turn, after the user stops looking, and — when the session died +without its defers — after graff itself. #199's postmortem found one such +tree alive for three days, hot on CPU, holding gigabytes and a port, while +the trajectory that started it had reported cleanup complete. + +The pool already owned the tree: a job leads its own process group (#198), +`bash_kill` and session end kill the group, a foreground run kills its +descendants even after a normal exit. What was missing was time (nothing +ever ended a forgotten job), a way to keep one on purpose, a record that +outlives the process that wrote it, and a place to see it all. + +## Decision + +1. **Silence is the idle clock.** Graff cannot see HTTP traffic without a + proxy, so activity is what it can see: bytes the job writes, a + `bash_output` read or blocking wait, the foreground wait, a pin. After + 30 minutes of none of these the user gets one dim line naming the stop + and the pin; after 2 hours the job's whole process group is killed and + the job stays listed as `idle-stop` with its command kept, so + `/jobs restart ` or a plain rerun brings it back. Both thresholds + are `GRAFF_JOB_IDLE_WARN_MINS` / `GRAFF_JOB_IDLE_STOP_MINS`; 0 turns a + step off. The model is told in the tool result and, at its next real step + boundary, in a notice — never as an idle auto-turn wake (ADR 0061: nobody + was there for two hours, and a wake would spend a turn telling nobody). + +2. **No SIGSTOP "pause".** A stopped process keeps its memory and its port + and cannot notice a request. Stopped-with-the-command-kept is the honest + pause; the restart is one command. + +3. **`/jobs keep ` pins.** A pinned job is exempt from the idle stop and + is *retained* at session end instead of killed: its stdout/stderr pipes + are handed to a detached `cat >/dev/null` (so a Node server does not die + of EPIPE on its next log line when graff's read ends close) and its + record is rewritten as `retained`. Pinning is a user action; the model + is told how to ask for it, not given a tool to do it. + +4. **One ownership record per job**, `~/.codegraff/jobs/.json`: leader + pid **and start identity** (#413), owner session pid and identity, + command, cwd, start time, pinned/retained. Written at spawn, removed when + the pump reaps the job, kept when the job is retained. It is what + survives a hard death of graff. + +5. **`graff servers [stop |prune]`** reads those records from any + directory: state (running / pinned / retained / gone / unknown), age, + owner session alive or gone, listening ports (read live via `lsof` by + process group — a server is not listening yet at spawn, so ports are not + stored), command, cwd. `stop` signals a group only when the leader still + carries the recorded start identity; a recycled pid, or one the OS will + not identify, is never touched. Nothing graff did not start appears here. + +## Consequences + +* A forgotten silent server costs at most two hours, not three days, and + the user is told before and after. A server that is being used keeps + logging and stays up; one that only serves HMR keepalives to a forgotten + tab logs nothing and stops — which is the intent of #199. +* A job that legitimately runs silent for hours (a long build with no + output) needs a pin or a raised `GRAFF_JOB_IDLE_STOP_MINS`; the 30-minute + notice says so. +* Retaining a pinned job leaves two `cat` processes alongside it until it + exits. Windows has no process groups, no `lsof` and no drainer: records + are written and listed, `stop` and retain report unsupported there. +* `jobs.zig` moved its `graff worktree` commands to `worktree_cmd.zig` to + stay under the file cap; callers are unchanged. + +## Not decided + +A port proxy that wakes a stopped server on the next request. It would +make the pause transparent, at the price of owning the port; revisit if +users pin more servers than they restart. diff --git a/docs/adr/0063-unsourced-cancel-is-the-harness.md b/docs/adr/0063-unsourced-cancel-is-the-harness.md new file mode 100644 index 00000000..584ee887 --- /dev/null +++ b/docs/adr/0063-unsourced-cancel-is-the-harness.md @@ -0,0 +1,37 @@ +# 0063. A cancel with no recorded source is the harness's, not the user's + +Status: accepted 2026-09-03 + +## Context + +`Agent.esc_cancel` is one process-wide flag with six setters — a lone Esc +on the line REPL's raw stdin, a double-Enter force steer, a `--json` +`cancel` line, an ACP `session/cancel`, the fullscreen TUI's cancel — and +the turn-ending marker always read `[response interrupted by user]`. #728 +reports that marker twice on responses the user never interrupted, with no +Esc, cancel, or stall event anywhere in the trace. Whatever flipped the flag +(the report correlates it with a background-job exit notice), the label was +wrong in two ways: it blamed the user, and it left no trace of the real +source, so the next occurrence could not be attributed either. + +## Decision + +* Every setter goes through `cancel_source.cancel(source)`, which records + who raised the flag. A fresh turn clears both together. +* Mainloop's `error.Interrupted` branch consumes the source. With a user + source the transcript keeps `[response interrupted by user]`; with none it + writes `[response ended early: cancelled by the harness, not the user]`, + the chrome line and the `--json` error say the same, and the trace carries + `interrupted source=` either way. +* The raw stdin scanner swallows an OSC sequence (a terminal's colour or + title *reply*) through its terminator instead of reading its leading ESC + as a keypress — one of the few ways the flag can rise without a hand on + the keyboard. +* A job exit the model already read no longer queues a wake (ADR 0061), + which removes the injected notice the report describes. + +## Consequences + +* A false interrupt is now labelled honestly and named in the trace; the + root cause of #728, if it recurs, is one trace line away instead of a + guess. The user-facing behaviour of a real Esc is unchanged. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2f95c5a5..93c74aa5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -72,6 +72,8 @@ record only when you need the evidence or the edge cases. | [0059](0059-saved-session-discovery-is-device-scoped.md) | `/resume` and `/sessions` list cwd then `~/.graff/sessions`; resume keeps tools in the current cwd. | | [0060](0060-named-source-gate-is-per-unanswered-mention.md) | The named-source nudge is per unanswered mention; identical user turns do not replay (#714). | | [0061](0061-tool-only-turns-narrate-and-ask-in-band.md) | Heads-up text rides in the same response as the tool calls; a choice for the user is an `ask_user` call; a job exit the model already read never wakes it. | +| [0062](0062-background-servers-idle-lifecycle-and-ownership-record.md) | A background job silent and unread for 2h is stopped with its command kept; `/jobs keep` pins one (retained at exit); every job has an ownership record `graff servers` can list and stop, verified by start identity (#199). | +| [0063](0063-unsourced-cancel-is-the-harness.md) | Every cancel records its source; a turn cancelled with no recorded source is labelled a harness cancel, never a user interrupt, and the source lands in the trace (#728). | ## When to write one diff --git a/gui/package.json b/gui/package.json index 5f8a4383..b3bbf379 100644 --- a/gui/package.json +++ b/gui/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@happy-dom/global-registrator": "^20.13.2", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2.10.1", diff --git a/gui/src/components/chat/ChatMarkdown.test.tsx b/gui/src/components/chat/ChatMarkdown.test.tsx new file mode 100644 index 00000000..a877e28c --- /dev/null +++ b/gui/src/components/chat/ChatMarkdown.test.tsx @@ -0,0 +1,116 @@ +import { afterAll, beforeAll, expect, mock, test } from "bun:test"; +import { act } from "react"; + +import { CHAT_BODY_TONE_CLASS } from "./constants/chatStyles"; + +// #729 asked for the final rendered assistant-message anchor, not only the +// matcher: the component the assistant row mounts (ChatMessageRow renders an +// assistant message as
), clicked the way a +// user clicks it, with the argument that reaches the open-link path captured +// verbatim. ChatMarkdown rather than ChatMessageRow because module mocks are +// process-wide under bun test and an earlier file may leave the desktop client +// as a partial stub; the row imports an export such a stub cannot gain. +const ISSUE_URL = "https://github.com/justrach/codegraff/issues/728"; + +const opened: string[] = []; + +mock.module("./utils/chatOpen", () => ({ + openUrlFromChat: (url: string) => { + opened.push(url); + }, + openFilePathFromChat: async () => {}, +})); + +let unregisterDom: (() => Promise) | null = null; +let createRoot: typeof import("react-dom/client").createRoot; +let ChatMarkdown: typeof import("./ChatMarkdown").ChatMarkdown; + +beforeAll(async () => { + // The DOM is registered before react-dom/client and the component are + // loaded, so both see a real document from the start. + const { GlobalRegistrator } = await import("@happy-dom/global-registrator"); + GlobalRegistrator.register(); + unregisterDom = () => GlobalRegistrator.unregister(); + // happy-dom leaves document.compatMode undefined; KaTeX (pulled in by the + // renderer) reads it at load and warns about quirks mode unless it says + // standards mode. + Object.defineProperty(document, "compatMode", { value: "CSS1Compat", configurable: true }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + ({ createRoot } = await import("react-dom/client")); + ({ ChatMarkdown } = await import("./ChatMarkdown")); +}); + +afterAll(async () => { + await unregisterDom?.(); +}); + +// Mounts the assistant message body with the props ChatMessageRow passes it. +function mountAssistantMessage(text: string) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +test("assistant message: a bold-wrapped bare URL renders bold, and both the anchor text and the open target are exactly the URL", () => { + opened.length = 0; + const { container, unmount } = mountAssistantMessage(`**${ISSUE_URL}**`); + try { + const anchor = container.querySelector("button"); + expect(anchor).not.toBeNull(); + // Neither delimiter pair survives as text: the whole message reads as the URL. + expect(anchor!.textContent).toBe(ISSUE_URL); + expect(container.textContent).toBe(ISSUE_URL); + expect(anchor!.getAttribute("title")).toBe(ISSUE_URL); + // The emphasis renders as bold around the anchor. + expect(anchor!.closest(".font-medium")).not.toBeNull(); + + // Cmd-click and a plain click both hand the open-link path exactly the URL. + act(() => { + anchor!.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true }), + ); + }); + act(() => { + anchor!.click(); + }); + expect(opened).toEqual([ISSUE_URL, ISSUE_URL]); + } finally { + unmount(); + } +}); + +test("assistant message: a bold-wrapped URL inside a sentence keeps punctuation and parentheses out of the target", () => { + opened.length = 0; + const { container, unmount } = mountAssistantMessage( + `Filed as **${ISSUE_URL}** (P0, see **${ISSUE_URL}**).`, + ); + try { + const anchors = Array.from(container.querySelectorAll("button")); + expect(anchors.map((anchor) => anchor.textContent)).toEqual([ISSUE_URL, ISSUE_URL]); + expect(container.textContent).toBe(`Filed as ${ISSUE_URL} (P0, see ${ISSUE_URL}).`); + for (const anchor of anchors) { + act(() => { + anchor.click(); + }); + } + expect(opened).toEqual([ISSUE_URL, ISSUE_URL]); + } finally { + unmount(); + } +}); diff --git a/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx b/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx index 8546bc22..76b7d367 100644 --- a/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx +++ b/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx @@ -88,6 +88,47 @@ test("keeps a bold-wrapped URL bold and its target clean", () => { expect(html).not.toContain("**"); }); +// #729: the exact report — a bare GitHub issue URL directly wrapped in bold. +// The anchor text and the target must both be the URL alone; the delimiter +// pairs are markup (bold), never text and never part of the link. +const ISSUE_URL = "https://github.com/justrach/codegraff/issues/728"; + +test("#729: a bold-wrapped bare URL renders bold with the anchor text and target exactly the URL", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("font-medium"); + expect(html).toContain(`>${ISSUE_URL}`); + expect(linkTargets(html)).toEqual([ISSUE_URL]); + expect(html).not.toContain("*"); +}); + +test("#729: bold-wrapped URLs next to punctuation, parentheses, and list markers stay clean", () => { + const texts = [ + `Filed **${ISSUE_URL}**.`, + `(see **${ISSUE_URL}**)`, + `- **${ISSUE_URL}** — P0`, + `**Filed: ${ISSUE_URL}** and **${ISSUE_URL}**`, + ]; + for (const text of texts) { + const html = renderToStaticMarkup(); + const targets = linkTargets(html); + expect(targets.length).toBeGreaterThan(0); + for (const target of targets) { + expect(target).toBe(ISSUE_URL); + } + expect(html).not.toContain("**"); + } +}); + +test("#729: no streaming prefix of a bold-wrapped URL leaks a delimiter into the target", () => { + const text = `Filed **${ISSUE_URL}** for the crash.`; + for (let length = 1; length <= text.length; length += 1) { + const html = renderToStaticMarkup(); + for (const target of linkTargets(html)) { + expect(target).not.toMatch(/(\*\*|__|~~)$/u); + } + } +}); + test("renders unsafe markdown link schemes as plain text", () => { const html = renderToStaticMarkup( , diff --git a/src/acp.zig b/src/acp.zig index 45423af6..7df0d06e 100644 --- a/src/acp.zig +++ b/src/acp.zig @@ -57,7 +57,7 @@ pub fn isAcpSubcommand(positional: []const u8) bool { } fn syncEscCancel() void { - agent_mod.Agent.esc_cancel.store(true, .release); + @import("cancel_source.zig").cancel(.acp_cancel); // #728 } fn liveCancelled() bool { diff --git a/src/agent.zig b/src/agent.zig index 9994ce05..0bc891b5 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -334,7 +334,7 @@ pub const Agent = struct { // No per-turn teardown: the socket and the chain span user turns, guarded by codex_chain.usable instead. self.completed = null; @import("named_work.zig").beginTurn(self); - if (!self.sub and !root_turn_prepared.swap(false, .acq_rel)) esc_cancel.store(false, .release); + if (!self.sub and !root_turn_prepared.swap(false, .acq_rel)) @import("cancel_source.zig").clear(); while (true) { if (try @import("turn_chrome.zig").beforeRequest(self)) |paused| return paused; // Esc during a tool join lands here; root consumes, subagents bail. @@ -515,7 +515,7 @@ pub const Agent = struct { /// Clear stale cancellation before the root becomes externally cancellable; /// runTurn consumes the marker without erasing a cancellation arriving later. pub fn prepareRootTurn() void { - esc_cancel.store(false, .release); + @import("cancel_source.zig").clear(); // flag + source (#728) root_turn_prepared.store(true, .release); } diff --git a/src/agent_interrupt.zig b/src/agent_interrupt.zig index 9d406bdf..0accccf1 100644 --- a/src/agent_interrupt.zig +++ b/src/agent_interrupt.zig @@ -20,6 +20,7 @@ const main_mod = @import("main.zig"); const agent_mod = @import("agent.zig"); const repl_glue = @import("repl_glue.zig"); const Agent = agent_mod.Agent; +const cancel_source = @import("cancel_source.zig"); // #728 const ansi = @import("ansi.zig"); const style = &ansi.style; @@ -29,7 +30,7 @@ const tty = terminal.tty; pub fn escWatchTask() void { while (!Agent.esc_watch_done.load(.acquire)) { if (tty.poll(100) and escPressed(false)) { - Agent.esc_cancel.store(true, .release); + cancel_source.cancelFromStdin(); return; } } @@ -93,6 +94,20 @@ pub fn escPressed(echo: bool) bool { // ESC O . Swallow the whole sequence. i = @min(i + 2, n - 1); continue; + } else if (i + 1 < n and buf[i + 1] == ']') { + // OSC — a terminal's colour/title REPLY (ESC ] … BEL, or ESC \\): + // never a keypress, so it must not read as Esc (#728). Swallow + // through the terminator, pulling a split tail in like CSI. + var j = i + 2; + while (true) { + while (j < n and buf[j] != 0x07 and !(buf[j] == 0x1b and j + 1 < n and buf[j + 1] == '\\')) : (j += 1) {} + if (j < n or n >= buf.len or !tty.poll(50)) break; + const more = tty.readStdin(buf[n..]); + if (more == 0) break; + n += more; + } + i = if (j < n) (if (buf[j] == 0x1b) j + 1 else j) else n - 1; + continue; } else if (i + 1 >= n) { // ESC is the LAST byte of this chunk — it may be the truncated // head of a split CSI/SS3/DSR sequence (e.g. a delayed @@ -242,3 +257,7 @@ pub fn sseIndex(obj: std.json.ObjectMap) ?usize { if (ix != .integer or ix.integer < 0) return null; return @intCast(ix.integer); } + +test { // #728: cancel_source has no other path into the test root + _ = cancel_source; +} diff --git a/src/cancel_source.zig b/src/cancel_source.zig new file mode 100644 index 00000000..37e4ccd8 --- /dev/null +++ b/src/cancel_source.zig @@ -0,0 +1,127 @@ +//! Who cancelled the turn (#728). `Agent.esc_cancel` is one process-wide +//! bool with six setters — a lone Esc on raw stdin, a double-Enter force +//! steer, a `--json` cancel line, an ACP session/cancel, the TUI's cancel — +//! and the turn-ending marker always read "[response interrupted by user]". +//! When the flag flips for any other reason, that label is wrong twice: it +//! blames the user, and it leaves no trace of the real source. Every setter +//! now goes through `cancel(source)`, and mainloop reads the source back: a +//! turn that ends cancelled with no recorded source is labelled a harness +//! cancel, not a user interrupt, and the source lands in the trace. + +const std = @import("std"); +const Agent = @import("agent.zig").Agent; +const main_mod = @import("main.zig"); +const Tracer = @import("trace.zig").Tracer; + +pub const Source = enum(u8) { + /// Nobody recorded one: the harness ended the turn, not the user. + none, + /// A lone Esc on the line REPL's raw stdin. + esc_key, + /// Double Enter with a queued steer line (escPressed marks it force). + force_steer, + /// --json `{"type":"cancel"}`. + json_cancel, + /// ACP `session/cancel`. + acp_cancel, + /// The fullscreen TUI's Esc / Ctrl+C / force / quit. + ui_cancel, +}; + +var source: std.atomic.Value(u8) = .init(0); + +/// Raise the flag and remember who did. +pub fn cancel(s: Source) void { + source.store(@intFromEnum(s), .release); + Agent.esc_cancel.store(true, .release); +} + +/// A cancel that came off raw stdin: a double Enter with a queued steer +/// line is the force path, anything else is a real Esc. +pub fn cancelFromStdin() void { + cancel(if (main_mod.g_force_interrupt) .force_steer else .esc_key); +} + +/// Fresh turn: the flag and the source clear together. +pub fn clear() void { + Agent.esc_cancel.store(false, .release); + source.store(0, .release); +} + +/// The source of the cancel that just ended a turn; consumed, and traced +/// (`interrupted source=…`) so the next false interrupt can be attributed. +pub fn take(tracer: ?*Tracer) Source { + const s: Source = @enumFromInt(source.swap(0, .acq_rel)); + if (tracer) |tr| { + var buf: [48]u8 = undefined; + tr.note("turn", note(&buf, s)); + } + return s; +} + +pub fn byUser(s: Source) bool { + return s != .none; +} + +/// The transcript marker appended as the (incomplete) assistant turn. +pub fn marker(s: Source) []const u8 { + return if (byUser(s)) "[response interrupted by user]" else "[response ended early: cancelled by the harness, not the user]"; +} + +/// The yellow chrome line the line REPL prints. +pub fn chrome(s: Source) []const u8 { + return switch (s) { + .esc_key => "✗ interrupted (esc)", + .force_steer => "✗ interrupted (force)", + .json_cancel, .acp_cancel, .ui_cancel => "✗ interrupted (cancel)", + .none => "✗ ended early (harness cancel — no user action recorded)", + }; +} + +/// The --json error message. +pub fn jsonMessage(s: Source) []const u8 { + return if (byUser(s)) "turn cancelled" else "turn cancelled by the harness (no user cancel recorded)"; +} + +/// The trace note: `interrupted source=esc_key`. +pub fn note(buf: []u8, s: Source) []const u8 { + return std.fmt.bufPrint(buf, "interrupted source={t}", .{s}) catch "interrupted"; +} + +test "cancel raises the flag with its source; take consumes; clear drops both" { + clear(); + try std.testing.expectEqual(Source.none, take(null)); + cancel(.json_cancel); + try std.testing.expect(Agent.esc_cancel.load(.acquire)); + try std.testing.expectEqual(Source.json_cancel, take(null)); + try std.testing.expectEqual(Source.none, take(null)); // consumed + cancel(.acp_cancel); + clear(); + try std.testing.expect(!Agent.esc_cancel.load(.acquire)); + try std.testing.expectEqual(Source.none, take(null)); +} + +test "cancelFromStdin is force after a double Enter, else Esc" { + const saved = main_mod.g_force_interrupt; + defer main_mod.g_force_interrupt = saved; + main_mod.g_force_interrupt = true; + cancelFromStdin(); + try std.testing.expectEqual(Source.force_steer, take(null)); + main_mod.g_force_interrupt = false; + cancelFromStdin(); + try std.testing.expectEqual(Source.esc_key, take(null)); + clear(); +} + +test "#728: an unsourced cancel is labelled a harness cancel, never a user one" { + try std.testing.expectEqualStrings("[response interrupted by user]", marker(.esc_key)); + try std.testing.expectEqualStrings("[response interrupted by user]", marker(.ui_cancel)); + try std.testing.expectEqualStrings("[response ended early: cancelled by the harness, not the user]", marker(.none)); + try std.testing.expect(std.mem.indexOf(u8, chrome(.none), "harness") != null); + try std.testing.expect(std.mem.indexOf(u8, chrome(.force_steer), "force") != null); + try std.testing.expect(std.mem.indexOf(u8, jsonMessage(.none), "harness") != null); + try std.testing.expectEqualStrings("turn cancelled", jsonMessage(.json_cancel)); + var buf: [48]u8 = undefined; + try std.testing.expectEqualStrings("interrupted source=none", note(&buf, .none)); + try std.testing.expectEqualStrings("interrupted source=acp_cancel", note(&buf, .acp_cancel)); +} diff --git a/src/cli.zig b/src/cli.zig index 022b121f..40a1e236 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -290,6 +290,8 @@ pub const usage_text = \\ graff worktree merge squash-land worktree- onto the current branch + clean up \\ graff worktree remove discard worktree- (drops its scratch work) + delete the branch \\ graff worktree prune drop git registrations for worktrees whose dirs were deleted + \\ graff servers background servers graff started (this session's or older): pid, port, age + \\ graff servers stop end one (its whole process tree); `prune` drops records of dead ones \\ graff sandboxes list your gateway sandboxes (what's burning credits) \\ graff sandboxes stop spin a sandbox down (stops it + settles the meter) \\ graff cube new spin up a cloud graff (sandbox + serve + preview URL) diff --git a/src/command_catalog.zig b/src/command_catalog.zig index 11f699a2..4f9234ef 100644 --- a/src/command_catalog.zig +++ b/src/command_catalog.zig @@ -71,7 +71,7 @@ pub const commands = [_]Item{ .{ .name = "/workspace", .usage = "/workspace [list|use ]", .desc = "list git worktrees or switch this session into one (file tools follow)" }, .{ .name = "/experiment", .usage = "/experiment [N|off|status]", .desc = "pre-mint N child worktrees (1-16) and seat the next spawns in them; off clears the pool" }, .{ .name = "/todo", .desc = "show the current task list" }, - .{ .name = "/jobs", .desc = "list background jobs" }, + .{ .name = "/jobs", .usage = "/jobs [keep|unkeep|stop|restart ]", .desc = "list background jobs (age, port, idle stop); keep pins a server, restart reruns one" }, .{ .name = "/cost", .desc = "session token usage and cost" }, .{ .name = "/usage", .desc = "alias for /cost" }, .{ .name = "/debug", .desc = "live content-free observability HUD (turns, tokens, tools, last events)" }, diff --git a/src/commands_jobs.zig b/src/commands_jobs.zig new file mode 100644 index 00000000..7c9a3b74 --- /dev/null +++ b/src/commands_jobs.zig @@ -0,0 +1,148 @@ +//! `/jobs` — background bash jobs and background agents, plus the #199 +//! controls: `keep`/`unkeep` pin a server (no idle stop, kept alive when the +//! session ends), `stop` kills one, `restart` reruns a finished one in its +//! cwd. Split out of commands_misc.zig. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const Agent = @import("agent.zig").Agent; +const jobs = @import("jobs.zig"); +const job_idle = @import("job_idle.zig"); +const job_registry = @import("job_registry.zig"); +const subagent = @import("subagent.zig"); // #276 P0-3: g_agent_jobs +const util = @import("util.zig"); +const tool_pulse = @import("tool_pulse.zig"); +const ansi = @import("ansi.zig"); +const style = &ansi.style; + +const posix = builtin.os.tag != .windows and builtin.os.tag != .wasi; + +pub const usage = "usage: /jobs [keep|unkeep|stop|restart ]"; + +pub fn tryHandle(root: *Agent, line: []const u8, out: *Io.Writer) !bool { + if (!std.mem.eql(u8, line, "/jobs") and !std.mem.startsWith(u8, line, "/jobs ")) return false; + const rest = std.mem.trim(u8, line["/jobs".len..], " \t"); + if (rest.len == 0) try list(root, out) else try control(root, rest, out); + try out.flush(); + return true; +} + +fn control(root: *Agent, rest: []const u8, out: *Io.Writer) !void { + var it = std.mem.tokenizeAny(u8, rest, " \t"); + const verb = it.next() orelse return out.print("{s}\n", .{usage}); + const id = std.fmt.parseInt(u32, it.next() orelse "", 10) catch return out.print("{s}\n", .{usage}); + if (std.mem.eql(u8, verb, "keep") or std.mem.eql(u8, verb, "unkeep")) { + const keep = verb[0] == 'k'; + const ok = jobs.setPinned(root.io, id, keep) orelse return out.print("no background job {d} — /jobs lists them\n", .{id}); + if (!ok) return out.print("job {d} has already finished\n", .{id}); + if (keep) { + try out.print("✓ job {d} pinned: no idle stop, kept alive when this session ends (`graff servers` finds it; /jobs unkeep {d} undoes)\n", .{ id, id }); + } else { + try out.print("✓ job {d} unpinned: the idle stop applies again and it ends with the session\n", .{id}); + } + return; + } + if (std.mem.eql(u8, verb, "stop")) { + const r = try jobs.jobKill(root.gpa, root.io, id); + defer root.gpa.free(r.text); + return out.print("{s}\n", .{r.text}); + } + if (std.mem.eql(u8, verb, "restart")) { + const job = jobs.restartJob(root.gpa, root.io, id) catch |err| return switch (err) { + error.NoSuchJob => out.print("no background job {d} — /jobs lists them\n", .{id}), + error.StillRunning => out.print("job {d} is still running — /jobs stop {d} first\n", .{ id, id }), + else => out.print("could not restart job {d} ({t})\n", .{ id, err }), + }; + return out.print("✓ job {d} started: {s} (restart of job {d})\n", .{ job.id, util.utf8Prefix(job.cmd, 60), id }); + } + try out.print("{s}\n", .{usage}); +} + +const Row = struct { + id: u32, + pid: i32, + running: bool, + status: []const u8, + age_ms: u64, + unread: usize, + cmd: []const u8, +}; + +/// Rows are snapshotted under the pool mutex and printed after it: the port +/// lookup spawns lsof, and the pumps need the mutex every 200ms. +fn list(root: *Agent, out: *Io.Writer) !void { + const io = root.io; + const arena = root.scratchAlloc(); + const now = util.unixMs(io); + var rows: std.ArrayList(Row) = .empty; + jobs.g_jobs.mutex.lockUncancelable(io); + for (jobs.g_jobs.list.items) |job| { + const status: []const u8 = if (!job.done) + (if (job.pinned) "pinned" else "running") + else if (job.stopped_idle) + "idle-stop" + else if (job.killed) + "killed" + else if (job.exit_code) |c| + (std.fmt.allocPrint(arena, "exit {d}", .{c}) catch "exited") + else + "abnormal"; + rows.append(arena, .{ + .id = job.id, + .pid = if (comptime posix) (job.child.id orelse 0) else 0, + .running = !job.done, + .status = status, + .age_ms = @intCast(@max(now - job.started_ms, 0)), + .unread = job.buf.items.len - job.cursor, + .cmd = arena.dupe(u8, util.utf8Prefix(job.cmd, 60)) catch "", + }) catch break; + } + jobs.g_jobs.mutex.unlock(io); + + if (rows.items.len == 0) { + try out.writeAll("no background bash jobs — the model starts one with bash {run_in_background: true}\n"); + } else { + var sbuf: [16]u8 = undefined; + const stop: []const u8 = if (job_idle.policy.stop_ms == 0) "off" else tool_pulse.formatElapsed(&sbuf, job_idle.policy.stop_ms); + try out.print("{s}background jobs{s} (idle stop: {s}; /jobs keep pins one, /jobs restart reruns one)\n", .{ style.bold, style.reset, stop }); + for (rows.items) |row| { + var abuf: [16]u8 = undefined; + const ports = if (row.running and row.pid != 0) job_registry.listenPorts(root.gpa, io, arena, row.pid) else ""; + try out.print(" {s}{d:>3}{s} {s}{s:<9}{s} {s:>7} {d:>7} unread B {s}{s}{s}\n", .{ + style.accent, row.id, style.reset, + if (row.running) style.green else style.dim, row.status, style.reset, + tool_pulse.formatElapsed(&abuf, row.age_ms), row.unread, row.cmd, + if (ports.len > 0) " ⇢ " else "", + ports, + }); + } + } + + // #276 P0-3: background subagents (subagent {run_in_background:true}), + // same listing shape as bash jobs above. + subagent.g_agent_jobs.mutex.lockUncancelable(io); + defer subagent.g_agent_jobs.mutex.unlock(io); + if (subagent.g_agent_jobs.list.items.len == 0) { + try out.writeAll("no background agents — the model starts one with subagent {run_in_background: true}\n"); + return; + } + try out.print("{s}background agents{s}\n", .{ style.bold, style.reset }); + for (subagent.g_agent_jobs.list.items) |job| { + const status: []const u8 = if (!job.admitted) + "queued" + else if (!job.done) + "running" + else if (job.is_error) + "failed" + else + "done"; + try out.print(" {s}{d:>3}{s} {s}{s:<8}{s} {d:>7}ms {s}\n", .{ + style.accent, job.id, style.reset, + if (job.done) style.dim else style.green, status, style.reset, + job.usage.duration_ms, utf8Prefix(job.label, 60), + }); + } +} + +const utf8Prefix = util.utf8Prefix; diff --git a/src/commands_misc.zig b/src/commands_misc.zig index 6c25bb9a..a3aae89e 100644 --- a/src/commands_misc.zig +++ b/src/commands_misc.zig @@ -31,7 +31,7 @@ const goal_flow = @import("goal_flow.zig"); const prompts = @import("prompts.zig"); // #445: the transcript line's compaction flag resets with the conversation const jobs = @import("jobs.zig"); const mcp_schema_gate = @import("mcp_schema_gate.zig"); // #416: which listed MCP tools are still schema-deferred -const subagent = @import("subagent.zig"); // #276 P0-3: g_agent_jobs, for /jobs +const commands_jobs = @import("commands_jobs.zig"); // /jobs family (#199) const models_table = @import("models_table.zig"); @@ -181,58 +181,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, try out.flush(); return true; } - if (std.mem.eql(u8, line, "/jobs")) { - jobs.g_jobs.mutex.lockUncancelable(root.io); - if (jobs.g_jobs.list.items.len == 0) { - try out.writeAll("no background bash jobs — the model starts one with bash {run_in_background: true}\n"); - } else { - try out.print("{s}background jobs{s}\n", .{ style.bold, style.reset }); - for (jobs.g_jobs.list.items) |job| { - var sbuf: [32]u8 = undefined; - const status: []const u8 = if (!job.done) - "running" - else if (job.killed) - "killed" - else if (job.exit_code) |c| - (std.fmt.bufPrint(&sbuf, "exit {d}", .{c}) catch "exited") - else - "abnormal"; - try out.print(" {s}{d:>3}{s} {s}{s:<8}{s} {d:>7} unread B {s}\n", .{ - style.accent, job.id, style.reset, - if (job.done) style.dim else style.green, status, style.reset, - job.buf.items.len - job.cursor, utf8Prefix(job.cmd, 60), - }); - } - } - jobs.g_jobs.mutex.unlock(root.io); - - // #276 P0-3: background subagents (subagent {run_in_background:true}), - // same listing shape as bash jobs above. - subagent.g_agent_jobs.mutex.lockUncancelable(root.io); - if (subagent.g_agent_jobs.list.items.len == 0) { - try out.writeAll("no background agents — the model starts one with subagent {run_in_background: true}\n"); - } else { - try out.print("{s}background agents{s}\n", .{ style.bold, style.reset }); - for (subagent.g_agent_jobs.list.items) |job| { - const status: []const u8 = if (!job.admitted) - "queued" - else if (!job.done) - "running" - else if (job.is_error) - "failed" - else - "done"; - try out.print(" {s}{d:>3}{s} {s}{s:<8}{s} {d:>7}ms {s}\n", .{ - style.accent, job.id, style.reset, - if (job.done) style.dim else style.green, status, style.reset, - job.usage.duration_ms, utf8Prefix(job.label, 60), - }); - } - } - subagent.g_agent_jobs.mutex.unlock(root.io); - try out.flush(); - return true; - } + if (try commands_jobs.tryHandle(root, line, out)) return true; // /jobs [keep|unkeep|stop|restart] (#199) if (std.mem.eql(u8, line, "/debug") or std.mem.eql(u8, line, "/cache")) { if (std.mem.eql(u8, line, "/debug")) try obs.renderHud(out) else try cache_hud.render(out); try out.flush(); diff --git a/src/http.zig b/src/http.zig index 2817e66c..86020f44 100644 --- a/src/http.zig +++ b/src/http.zig @@ -14,6 +14,7 @@ const provider_mod = @import("provider.zig"); const agent_mod = @import("agent.zig"); const Provider = provider_mod.Provider; const Agent = agent_mod.Agent; +const cancel_source = @import("cancel_source.zig"); // #728 const headers = @import("http_headers.zig"); const stall = @import("http_stall.zig"); // #56: the watchdogs' pure budget arithmetic const http_client = @import("http_client.zig"); @@ -306,7 +307,7 @@ pub fn streamStallWatch(io: Io, poll_stdin: bool, tokens_flowing: bool) Watchdog io.sleep(.fromMilliseconds(50), .awake) catch return .deadline; // canceled: a line arrived waited += 50; if (poll_stdin and Agent.drainSteerStdin(true)) { - Agent.esc_cancel.store(true, .release); + cancel_source.cancelFromStdin(); return .esc; } if (Agent.esc_cancel.load(.acquire)) return .esc; @@ -322,7 +323,7 @@ pub fn deadlineStallTask(io: Io, poll_stdin: bool, budget_ms: u64) WatchdogFired io.sleep(.fromMilliseconds(50), .awake) catch return .deadline; waited += 50; if (poll_stdin and Agent.drainSteerStdin(true)) { - Agent.esc_cancel.store(true, .release); + cancel_source.cancelFromStdin(); return .esc; } if (Agent.esc_cancel.load(.acquire)) return .esc; diff --git a/src/job_idle.zig b/src/job_idle.zig new file mode 100644 index 00000000..471bd5fa --- /dev/null +++ b/src/job_idle.zig @@ -0,0 +1,131 @@ +//! Idle lifecycle for background bash jobs (#199). A dev server graff +//! started can outlive the turn, the session's attention, and — per the +//! postmortem on #199 — three days of a machine's memory and a port. The +//! activity graff can see without proxying traffic: bytes the job wrote, a +//! bash_output read or blocking wait, a /jobs touch. Past `warn_ms` of that +//! silence the user gets one dim notice naming the stop and the pin; past +//! `stop_ms` the job's whole process group is killed and the job stays +//! listed as stopped-idle with its command, so `/jobs restart` or a plain +//! rerun brings it back. Pinned jobs (`/jobs keep`) are exempt. +//! +//! No SIGSTOP "pause" state: it keeps the memory and the port and cannot +//! notice a request. Stopped-with-the-command-kept is the honest pause. + +const std = @import("std"); +const Io = std.Io; +const tool_pulse = @import("tool_pulse.zig"); +const util = @import("util.zig"); + +pub const Policy = struct { + warn_ms: u64 = 30 * std.time.ms_per_min, + stop_ms: u64 = 2 * std.time.ms_per_hour, +}; + +/// Session policy. GRAFF_JOB_IDLE_WARN_MINS / GRAFF_JOB_IDLE_STOP_MINS +/// override the defaults; 0 turns that step off. +pub var policy: Policy = .{}; + +pub const Verdict = enum { none, warn, stop }; + +pub fn verdict(idle_ms: u64, warned: bool, pinned: bool) Verdict { + return verdictUnder(policy, idle_ms, warned, pinned); +} + +pub fn verdictUnder(p: Policy, idle_ms: u64, warned: bool, pinned: bool) Verdict { + if (pinned) return .none; + if (p.stop_ms != 0 and idle_ms >= p.stop_ms) return .stop; + if (!warned and p.warn_ms != 0 and idle_ms >= p.warn_ms) return .warn; + return .none; +} + +/// Minutes; unparseable values are ignored, 0 disables, a week is the cap. +pub fn applyEnv(environ_map: anytype) void { + if (environ_map.get("GRAFF_JOB_IDLE_WARN_MINS")) |v| { + if (parseMins(v)) |ms| policy.warn_ms = ms; + } + if (environ_map.get("GRAFF_JOB_IDLE_STOP_MINS")) |v| { + if (parseMins(v)) |ms| policy.stop_ms = ms; + } +} + +fn parseMins(v: []const u8) ?u64 { + const mins = std.fmt.parseInt(u64, std.mem.trim(u8, v, " \t"), 10) catch return null; + const capped: u64 = @min(mins, 7 * 24 * 60); + return capped * std.time.ms_per_min; +} + +/// The one dim chrome line at the warn threshold. +pub fn warnLine(buf: []u8, id: u32, idle_ms: u64, stop_ms: u64, cmd: []const u8) []const u8 { + var a: [16]u8 = undefined; + var b: [16]u8 = undefined; + return std.fmt.bufPrint(buf, "· job {d} idle {s} ({s}) · stops after {s} idle · /jobs keep {d} pins it", .{ + id, tool_pulse.formatElapsed(&a, idle_ms), util.utf8Prefix(cmd, 32), tool_pulse.formatElapsed(&b, stop_ms), id, + }) catch buf[0..0]; +} + +/// Pump thread: paint the warn line (hosted sink or line-REPL stdout). +pub fn warn(io: Io, id: u32, idle_ms: u64, cmd: []const u8) void { + var buf: [160]u8 = undefined; + const line = warnLine(&buf, id, idle_ms, policy.stop_ms, cmd); + if (line.len > 0) tool_pulse.emitNotice(io, "{s}", .{line}); +} + +/// The bash_output status line for a job the policy stopped. +pub fn printStopped(w: *Io.Writer, id: u32, stop_ms: u64) !void { + var b: [16]u8 = undefined; + try w.print("[job {d}: stopped after {s} with no output and no reads — rerun it if it is still needed; the user pins a long-lived server with /jobs keep {d}]", .{ id, tool_pulse.formatElapsed(&b, stop_ms), id }); +} + +test "verdict: warn once past warn_ms, stop past stop_ms, nothing before" { + const p: Policy = .{ .warn_ms = 1000, .stop_ms = 5000 }; + try std.testing.expectEqual(Verdict.none, verdictUnder(p, 999, false, false)); + try std.testing.expectEqual(Verdict.warn, verdictUnder(p, 1000, false, false)); + try std.testing.expectEqual(Verdict.none, verdictUnder(p, 4999, true, false)); // already warned + try std.testing.expectEqual(Verdict.stop, verdictUnder(p, 5000, true, false)); + try std.testing.expectEqual(Verdict.stop, verdictUnder(p, 5000, false, false)); // a stop needs no prior warn +} + +test "verdict: a pinned job is never warned or stopped; 0 turns a step off" { + const p: Policy = .{ .warn_ms = 1000, .stop_ms = 5000 }; + try std.testing.expectEqual(Verdict.none, verdictUnder(p, 1_000_000, false, true)); + const no_stop: Policy = .{ .warn_ms = 1000, .stop_ms = 0 }; + try std.testing.expectEqual(Verdict.warn, verdictUnder(no_stop, 1_000_000, false, false)); + try std.testing.expectEqual(Verdict.none, verdictUnder(no_stop, 1_000_000, true, false)); + const off: Policy = .{ .warn_ms = 0, .stop_ms = 0 }; + try std.testing.expectEqual(Verdict.none, verdictUnder(off, 1_000_000, false, false)); +} + +test "applyEnv: minutes to ms, 0 disables, junk ignored, a week caps" { + const saved = policy; + defer policy = saved; + const Env = struct { + warn: ?[]const u8, + stop: ?[]const u8, + pub fn get(self: @This(), name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, name, "GRAFF_JOB_IDLE_WARN_MINS")) return self.warn; + if (std.mem.eql(u8, name, "GRAFF_JOB_IDLE_STOP_MINS")) return self.stop; + return null; + } + }; + policy = .{}; + applyEnv(Env{ .warn = "5", .stop = "0" }); + try std.testing.expectEqual(@as(u64, 5 * std.time.ms_per_min), policy.warn_ms); + try std.testing.expectEqual(@as(u64, 0), policy.stop_ms); + applyEnv(Env{ .warn = "junk", .stop = "99999999" }); + try std.testing.expectEqual(@as(u64, 5 * std.time.ms_per_min), policy.warn_ms); + try std.testing.expectEqual(@as(u64, 7 * 24 * 60 * std.time.ms_per_min), policy.stop_ms); +} + +test "warnLine names the job, the idle time, the stop, and the pin" { + var buf: [160]u8 = undefined; + const line = warnLine(&buf, 3, 30 * std.time.ms_per_min, 2 * std.time.ms_per_hour, "npm run dev -p 3002"); + try std.testing.expectEqualStrings("· job 3 idle 30m00s (npm run dev -p 3002) · stops after 2h00m idle · /jobs keep 3 pins it", line); +} + +test "printStopped tells the model the job is gone and how it comes back" { + var aw: Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try printStopped(&aw.writer, 7, 2 * std.time.ms_per_hour); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "[job 7: stopped after 2h00m") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "/jobs keep 7") != null); +} diff --git a/src/job_notify.zig b/src/job_notify.zig index 074b2970..37c32591 100644 --- a/src/job_notify.zig +++ b/src/job_notify.zig @@ -14,6 +14,7 @@ pub const Notice = struct { id: u32, exit_code: ?u8 = null, killed: bool = false, + idle: bool = false, // the idle policy stopped it (#199): no auto-turn wake preview: [48]u8 = undefined, preview_len: u8 = 0, }; @@ -39,6 +40,9 @@ fn clipCmd(cmd: []const u8) struct { buf: [48]u8, len: u8 } { /// One human/model line for a finished job. pub fn line(buf: []u8, n: Notice) []const u8 { const cmd = n.preview[0..n.preview_len]; + if (n.idle) { + return std.fmt.bufPrint(buf, "[job {d} stopped idle: {s}]", .{ n.id, cmd }) catch buf[0..0]; + } if (n.killed) { return std.fmt.bufPrint(buf, "[job {d} killed: {s}]", .{ n.id, cmd }) catch buf[0..0]; } @@ -51,6 +55,7 @@ pub fn line(buf: []u8, n: Notice) []const u8 { fn wakeLine(buf: []u8, n: Notice) []const u8 { var head: [96]u8 = undefined; const h = line(&head, n); + if (n.idle) return std.fmt.bufPrint(buf, "{s} — silent and unread past the idle stop; rerun it only if it is still needed.", .{h}) catch h; return std.fmt.bufPrint(buf, "{s} — unread output via bash_output; do not poll.", .{h}) catch h; } @@ -58,9 +63,9 @@ fn wakeLine(buf: []u8, n: Notice) []const u8 { /// paints a TUI/REPL notice immediately. A job whose exit the model already /// read (dismiss ran first) still reaches the hosted sink — that event is /// UI, not a wake. -pub fn record(io: Io, id: u32, exit_code: ?u8, killed: bool, cmd: []const u8) void { +pub fn record(io: Io, id: u32, exit_code: ?u8, killed: bool, cmd: []const u8, idle: bool) void { const clipped = clipCmd(cmd); - const n = Notice{ .id = id, .exit_code = exit_code, .killed = killed, .preview = clipped.buf, .preview_len = clipped.len }; + const n = Notice{ .id = id, .exit_code = exit_code, .killed = killed, .idle = idle, .preview = clipped.buf, .preview_len = clipped.len }; mu.lockUncancelable(io); if (dismissedIndex(id)) |i| { std.mem.copyForwards(u32, dismissed[i .. dismissed_len - 1], dismissed[i + 1 .. dismissed_len]); @@ -136,15 +141,34 @@ pub fn stillRunning(io: Io, id: u32, waited_ms: u64) void { tool_pulse.emitNotice(io, "· bash_output · job {d} still running · {s}", .{ id, tool_pulse.formatElapsed(&ebuf, waited_ms) }); } -/// Drain queued notices into `buf`. Null when nothing finished. +/// Drain queued notices into `buf` (step boundary: everything). Null when +/// nothing finished. pub fn takeWake(io: Io, buf: []u8) ?[]const u8 { + return drain(io, buf, true); +} + +/// The idle-TUI auto-turn: an exit or a kill wakes the model; an idle stop +/// does not — nobody was there for the whole idle budget, and a wake would +/// spend a turn telling nobody (ADR 0061). It waits in the ring for the next +/// real step boundary, where `deliver` hands it over. +pub fn takeIdleWake(io: Io, buf: []u8) ?[]const u8 { + return drain(io, buf, false); +} + +fn drain(io: Io, buf: []u8, idle_too: bool) ?[]const u8 { mu.lockUncancelable(io); defer mu.unlock(io); if (count == 0) return null; var used: usize = 0; + var keep: usize = 0; // notices left in the ring, compacted in order var i: usize = 0; while (i < count) : (i += 1) { - var one: [160]u8 = undefined; + if (!idle_too and ring[i].idle) { + ring[keep] = ring[i]; + keep += 1; + continue; + } + var one: [200]u8 = undefined; const w = wakeLine(&one, ring[i]); if (used > 0) { if (used + 1 >= buf.len) break; @@ -155,7 +179,11 @@ pub fn takeWake(io: Io, buf: []u8) ?[]const u8 { @memcpy(buf[used .. used + n], w[0..n]); used += n; } - count = 0; + while (i < count) : (i += 1) { // what did not fit stays for next time + ring[keep] = ring[i]; + keep += 1; + } + count = keep; if (used == 0) return null; return buf[0..used]; } @@ -191,22 +219,22 @@ test "dismiss drops a queued notice, or the one the pump has not queued yet (ADR dismissed_len = 0; var buf: [256]u8 = undefined; // Queued, then read through bash_output: no wake. - record(io, 7, 0, false, "sleep 1"); + record(io, 7, 0, false, "sleep 1", false); dismiss(io, 7); try std.testing.expect(takeWake(io, &buf) == null); // Read through bash_output BEFORE the pump queued it: still no wake, and // the remembered id is spent by that one record. dismiss(io, 8); dismiss(io, 8); // a second read of the same finished job is not a second credit - record(io, 8, 1, false, "false"); + record(io, 8, 1, false, "false", false); try std.testing.expect(takeWake(io, &buf) == null); try std.testing.expectEqual(@as(usize, 0), dismissed_len); - record(io, 8, 1, false, "false"); + record(io, 8, 1, false, "false", false); try std.testing.expect(std.mem.indexOf(u8, takeWake(io, &buf) orelse "", "[job 8 exited 1: false]") != null); // Only the dismissed id is dropped; a neighbour in the ring survives. - record(io, 9, 0, false, "a"); - record(io, 10, 0, false, "b"); - record(io, 11, 0, false, "c"); + record(io, 9, 0, false, "a", false); + record(io, 10, 0, false, "b", false); + record(io, 11, 0, false, "c", false); dismiss(io, 10); const text = takeWake(io, &buf) orelse return error.Empty; try std.testing.expect(std.mem.indexOf(u8, text, "[job 9 exited 0: a]") != null); @@ -228,8 +256,8 @@ test "takeWake drains and formats the grok-build do-not-poll reminder" { const io = std.testing.io; count = 0; dismissed_len = 0; - record(io, 1, 0, false, "true"); - record(io, 2, 1, false, "false"); + record(io, 1, 0, false, "true", false); + record(io, 2, 1, false, "false", false); var buf: [256]u8 = undefined; const text = takeWake(io, &buf) orelse return error.Empty; try std.testing.expect(std.mem.indexOf(u8, text, "[job 1 exited 0: true]") != null); @@ -237,3 +265,20 @@ test "takeWake drains and formats the grok-build do-not-poll reminder" { try std.testing.expect(std.mem.indexOf(u8, text, "[job 2 exited 1: false]") != null); try std.testing.expect(takeWake(io, &buf) == null); } + +test "#199: an idle stop is a step-boundary notice, not an auto-turn wake" { + const io = std.testing.io; + count = 0; + dismissed_len = 0; + var buf: [512]u8 = undefined; + record(io, 3, null, true, "npm run dev", true); + try std.testing.expect(takeIdleWake(io, &buf) == null); // the idle TUI stays idle + record(io, 4, 0, false, "make", false); + const idle_text = takeIdleWake(io, &buf) orelse return error.Empty; + try std.testing.expect(std.mem.indexOf(u8, idle_text, "[job 4 exited 0: make]") != null); + try std.testing.expect(std.mem.indexOf(u8, idle_text, "job 3") == null); // still queued + const step_text = takeWake(io, &buf) orelse return error.Empty; + try std.testing.expect(std.mem.indexOf(u8, step_text, "[job 3 stopped idle: npm run dev]") != null); + try std.testing.expect(std.mem.indexOf(u8, step_text, "rerun it only if it is still needed") != null); + try std.testing.expect(takeWake(io, &buf) == null); +} diff --git a/src/job_registry.zig b/src/job_registry.zig new file mode 100644 index 00000000..873e5391 --- /dev/null +++ b/src/job_registry.zig @@ -0,0 +1,252 @@ +//! Ownership records for background jobs (#199): `~/.codegraff/jobs/.json`, +//! one per live job, written at spawn and removed when the pump reaps it. +//! The record is what outlives a graff that died without running its defers +//! (SIGKILL, a closed terminal): the process tree may still be alive, and +//! `graff servers` reads these to find it, show its ports, and stop it — only +//! ever a tree graff itself started, checked against the leader's pid AND +//! start identity (#413) so a recycled pid never gets an unrelated process +//! killed. A pinned job the session keeps on exit stays here, `retained`. +//! +//! Ports are not stored: a server is not listening yet when it is spawned. +//! They are read live (`lsof` by process group) when someone looks. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const proc_identity = @import("proc_identity.zig"); +const process_runner = @import("process_runner.zig"); + +const posix = builtin.os.tag != .windows and builtin.os.tag != .wasi; + +/// The process $HOME, pinned at startup beside oauth.initHome. "" (tests, no +/// HOME) means no on-disk record; the in-session pool is unaffected. +pub var home: []const u8 = ""; + +pub const Record = struct { + pid: i32, + start_id: u64 = 0, + owner_pid: i32 = 0, + owner_start_id: u64 = 0, + cmd: []const u8, + cwd: []const u8 = "", + started_ms: i64 = 0, + pinned: bool = false, + retained: bool = false, +}; + +pub fn dirPath(buf: []u8, base: []const u8) ?[]const u8 { + if (base.len == 0) return null; + return std.fmt.bufPrint(buf, "{s}/.codegraff/jobs", .{base}) catch null; +} + +fn recordPath(buf: []u8, base: []const u8, pid: i32) ?[]const u8 { + if (base.len == 0) return null; + return std.fmt.bufPrint(buf, "{s}/.codegraff/jobs/{d}.json", .{ base, pid }) catch null; +} + +/// Write (or rewrite) one record. Best-effort: no HOME or an unwritable dir +/// costs the cross-session view, never the job. +pub fn write(io: Io, base: []const u8, rec: Record) void { + var dbuf: [std.fs.max_path_bytes]u8 = undefined; + const dir = dirPath(&dbuf, base) orelse return; + Io.Dir.cwd().createDirPath(io, dir) catch return; + var pbuf: [std.fs.max_path_bytes]u8 = undefined; + const path = recordPath(&pbuf, base, rec.pid) orelse return; + var jbuf: [16 * 1024]u8 = undefined; + var w: Io.Writer = .fixed(&jbuf); + var s: std.json.Stringify = .{ .writer = &w }; + s.write(rec) catch return; + Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = w.buffered() }) catch return; +} + +pub fn forget(io: Io, base: []const u8, pid: i32) void { + var pbuf: [std.fs.max_path_bytes]u8 = undefined; + const path = recordPath(&pbuf, base, pid) orelse return; + Io.Dir.cwd().deleteFile(io, path) catch {}; +} + +fn earlier(_: void, a: Record, b: Record) bool { + return a.started_ms < b.started_ms; +} + +/// Every record on disk, oldest first, arena-owned. +pub fn list(io: Io, arena: Allocator, base: []const u8) []const Record { + var out: std.ArrayList(Record) = .empty; + var dbuf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path = dirPath(&dbuf, base) orelse return &.{}; + var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return &.{}; + defer dir.close(io); + var it = dir.iterate(); + while (it.next(io) catch null) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".json")) continue; + const data = dir.readFileAlloc(io, entry.name, arena, .limited(64 * 1024)) catch continue; + const rec = std.json.parseFromSliceLeaky(Record, arena, data, .{ .ignore_unknown_fields = true }) catch continue; + out.append(arena, rec) catch break; + } + std.mem.sort(Record, out.items, {}, earlier); + return out.items; +} + +pub const State = enum { running, gone, unverifiable }; + +/// Is the recorded leader still the process we started? A live pid with a +/// different start identity is a recycled pid: gone, and never signalled. +pub fn state(io: Io, rec: Record) State { + return stateOf(rec.start_id, proc_identity.probe(io, rec.pid)); +} + +pub fn stateOf(start_id: u64, live: proc_identity.Probe) State { + return switch (live) { + .gone => .gone, + .unknown => .unverifiable, + .id => |v| if (start_id == 0 or v == start_id) .running else .gone, + }; +} + +/// Is the graff session that started it still alive? +pub fn ownerAlive(io: Io, rec: Record) bool { + if (rec.owner_pid <= 0) return false; + return stateOf(rec.owner_start_id, proc_identity.probe(io, rec.owner_pid)) != .gone; +} + +/// Listening TCP sockets of the job's process group, `127.0.0.1:3002, *:3003` +/// style, or "" (Windows, no lsof, nothing listening). The leader was spawned +/// as its own group, so the group id is its pid. +pub fn listenPorts(gpa: Allocator, io: Io, arena: Allocator, pid: i32) []const u8 { + if (!posix) return ""; + var pidbuf: [16]u8 = undefined; + const pid_s = std.fmt.bufPrint(&pidbuf, "{d}", .{pid}) catch return ""; + const run = process_runner.runCapped(gpa, io, &.{ "lsof", "-a", "-P", "-n", "-iTCP", "-sTCP:LISTEN", "-g", pid_s, "-Fn" }, 64 * 1024, 4096, 5_000) catch return ""; + defer { + gpa.free(run.stdout); + gpa.free(run.stderr); + } + return parseLsofPorts(arena, run.stdout); +} + +/// `lsof -Fn`: one `n` line per socket (`n127.0.0.1:3002`, `n*:3003`) +/// between `p` / `f` lines. Deduped, joined with ", ". +pub fn parseLsofPorts(arena: Allocator, text: []const u8) []const u8 { + var out: std.ArrayList(u8) = .empty; + var seen: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len < 2 or line[0] != 'n') continue; + const addr = line[1..]; + var dup = false; + for (seen.items) |s| { + if (std.mem.eql(u8, s, addr)) dup = true; + } + if (dup) continue; + seen.append(arena, addr) catch break; + if (out.items.len > 0) out.appendSlice(arena, ", ") catch break; + out.appendSlice(arena, addr) catch break; + } + return out.items; +} + +pub const StopResult = enum { stopped, gone, unverifiable, unsupported }; + +/// TERM the whole group, give it 2s, then KILL — only when the leader still +/// carries the recorded start identity. +pub fn stopTree(io: Io, rec: Record) StopResult { + if (!posix) return .unsupported; + switch (state(io, rec)) { + .gone => return .gone, + .unverifiable => return .unverifiable, + .running => {}, + } + std.posix.kill(-rec.pid, .TERM) catch return .gone; + var waited: u64 = 0; + while (waited < 2000) : (waited += 100) { + io.sleep(.fromMilliseconds(100), .awake) catch break; + if (proc_identity.probe(io, rec.pid) == .gone) return .stopped; + } + std.posix.kill(-rec.pid, .KILL) catch {}; + return .stopped; +} + +/// Session end for a pinned job: hand each of its pipes to a detached +/// `cat >/dev/null` so the server never sees EPIPE once graff's read ends +/// close with the process (a Node dev server dies on its next log line +/// otherwise), then rewrite the record as retained. False when a drainer +/// could not start — the caller kills the job rather than leak a +/// half-detached one. +pub fn retain(io: Io, base: []const u8, rec: Record, stdout: ?Io.File, stderr: ?Io.File) bool { + if (!posix) return false; + for ([_]?Io.File{ stdout, stderr }) |maybe| { + const f = maybe orelse continue; + _ = std.process.spawn(io, .{ + .argv = &.{ "/bin/sh", "-c", "exec cat >/dev/null 2>&1" }, + .stdin = .{ .file = f }, + .stdout = .ignore, + .stderr = .ignore, + .pgid = 0, + }) catch return false; + } + var kept = rec; + kept.pinned = true; + kept.retained = true; + write(io, base, kept); + return true; +} + +test "parseLsofPorts: n lines only, deduped, joined" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = "p4242\nf12\nn127.0.0.1:3002\nf13\nn127.0.0.1:3002\np4250\nf5\nn*:3003\n"; + try std.testing.expectEqualStrings("127.0.0.1:3002, *:3003", parseLsofPorts(arena, text)); + try std.testing.expectEqualStrings("", parseLsofPorts(arena, "")); + try std.testing.expectEqualStrings("", parseLsofPorts(arena, "p1\nf2\n")); +} + +test "stateOf: a recycled pid is gone, an opaque one is unverifiable, legacy 0 trusts the pid" { + try std.testing.expectEqual(State.running, stateOf(77, .{ .id = 77 })); + try std.testing.expectEqual(State.gone, stateOf(77, .{ .id = 78 })); + try std.testing.expectEqual(State.running, stateOf(0, .{ .id = 78 })); + try std.testing.expectEqual(State.gone, stateOf(77, .gone)); + try std.testing.expectEqual(State.unverifiable, stateOf(77, .unknown)); +} + +test "records: write, list oldest-first, forget; no HOME means no record" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(io, &buf); + const base = try arena.dupe(u8, buf[0..n]); + + write(io, base, .{ .pid = 200, .start_id = 5, .cmd = "next dev", .cwd = "/srv/site", .started_ms = 2_000 }); + write(io, base, .{ .pid = 100, .start_id = 4, .owner_pid = 9, .cmd = "sleep 30", .started_ms = 1_000, .pinned = true }); + const recs = list(io, arena, base); + try std.testing.expectEqual(@as(usize, 2), recs.len); + try std.testing.expectEqual(@as(i32, 100), recs[0].pid); + try std.testing.expect(recs[0].pinned); + try std.testing.expectEqual(@as(i32, 9), recs[0].owner_pid); + try std.testing.expectEqualStrings("next dev", recs[1].cmd); + try std.testing.expectEqualStrings("/srv/site", recs[1].cwd); + try std.testing.expectEqual(@as(u64, 5), recs[1].start_id); + forget(io, base, 200); + try std.testing.expectEqual(@as(usize, 1), list(io, arena, base).len); + write(io, "", .{ .pid = 300, .cmd = "nowhere" }); + try std.testing.expectEqual(@as(usize, 0), list(io, arena, "").len); +} + +test "stopTree never signals a pid whose start identity is not ours" { + if (!posix) return error.SkipZigTest; + const io = std.testing.io; + const me = proc_identity.selfRecord(io); + if (me.start_id == 0) return error.SkipZigTest; // no identity source here + // Our own pid with a wrong start id reads as a recycled pid: gone, no kill. + const forged: Record = .{ .pid = me.pid, .start_id = me.start_id +% 1, .cmd = "not us" }; + try std.testing.expectEqual(State.gone, state(io, forged)); + try std.testing.expectEqual(StopResult.gone, stopTree(io, forged)); + const real: Record = .{ .pid = me.pid, .start_id = me.start_id, .cmd = "us" }; + try std.testing.expectEqual(State.running, state(io, real)); +} diff --git a/src/jobs.zig b/src/jobs.zig index cdaa60d7..44659c0a 100644 --- a/src/jobs.zig +++ b/src/jobs.zig @@ -17,11 +17,6 @@ pub const runCapped = process_runner.runCapped; pub const runCappedWithOptions = process_runner.runCappedWithOptions; pub const ranOk = process_runner.ranOk; -/// Commit-message trailer that credits the harness assist. The commit AUTHOR -/// stays the user's own git identity (their GitHub account) — graff never -/// overrides GIT_AUTHOR_*; codegraff is recorded as a co-author instead, -/// mirroring how Claude Code attributes commits. -const codegraff_coauthor = "Co-Authored-By: Codegraff "; const Agent = agent_mod.Agent; /// Same as `runCapped`, but spawns the child with an explicit working @@ -47,6 +42,13 @@ pub fn toolRunOptions(cwd: ?[]const u8) CappedRunOptions { }; } +// `graff worktree …` + the per-turn checkpoint commit live in worktree_cmd.zig +// (moved out when the pool grew its idle lifecycle, #199); callers still reach +// them through jobs. +const worktree_cmd = @import("worktree_cmd.zig"); +pub const worktreeAutoCommit = worktree_cmd.worktreeAutoCommit; +pub const worktreeCommand = worktree_cmd.worktreeCommand; + const agent_worktree = @import("agent_worktree.zig"); pub const AgentWorktree = agent_worktree.AgentWorktree; pub const AgentWorktreeError = agent_worktree.AgentWorktreeError; @@ -59,166 +61,18 @@ pub const agentWorktreeFinish = agent_worktree.agentWorktreeFinish; pub const KeepReason = agent_worktree.KeepReason; pub const keepReasonText = agent_worktree.keepReasonText; -// #112 (list age column + `prune --older-than`) and #320 (canonical worktree -// identity) live in their own modules: jobs.zig is at the 600-line cap. -const worktree_prune = @import("worktree_prune.zig"); - -/// Per-turn checkpoint commit for `-w` sessions. The worktree branch is a -/// throwaway scratch branch, so committing every turn is free and gives durable -/// rewind points across restarts; `graff worktree merge` later --squashes the -/// whole trail into one clean commit. No-op outside a worktree or under -/// --no-autocommit. Best-effort: a clean tree (nothing to commit) or a missing -/// git identity just means no commit this turn, never a failed turn. --no-verify -/// so a slow or strict pre-commit hook can't block a checkpoint. -pub fn worktreeAutoCommit(gpa: Allocator, io: Io, msg: []const u8) void { - if (root.g_worktree_branch == null or !root.g_worktree_autocommit) return; - // Stage everything except graff's own runtime artifacts — trace/trajectory/ - // sessions/keys/MCP config must never ride into the squash-merge onto the - // user's branch. .gitignore hides these in the graff repo, but a *target* - // repo (the swarm's real use case) won't, so exclude them explicitly here. - const add = runCapped(gpa, io, &.{ - "git", "add", - "-A", "--", - ":(exclude).graff", ":(exclude).harness", - ":(exclude)harness.*.jsonl", ":(exclude)*.session.json", - ":(exclude).mcp.json", ":(exclude).simple-harness-*", - }, 4096, 4096, 30_000) catch return; - gpa.free(add.stdout); - gpa.free(add.stderr); - // Author stays the user's git identity; codegraff rides as a co-author trailer. - const full = std.fmt.allocPrint(gpa, "{s}\n\n{s}", .{ msg, codegraff_coauthor }) catch msg; - defer if (full.ptr != msg.ptr) gpa.free(full); - const c = runCapped(gpa, io, &.{ "git", "commit", "--no-verify", "-m", full }, 8192, 8192, 30_000) catch return; - gpa.free(c.stdout); - gpa.free(c.stderr); -} - -/// `graff worktree >` — manage the per-tab scratch worktrees -/// that `-w` creates. `list` shows them; `merge ` squash-merges -/// worktree- into the current branch as one clean commit, then removes the -/// worktree and deletes its branch. Run from the main checkout. -pub fn worktreeCommand(gpa: Allocator, io: Io, arena: Allocator, args: []const []const u8) !void { - var buf: [4096]u8 = undefined; - var w = Io.File.stdout().writer(io, &buf); - const out = &w.interface; - defer out.flush() catch {}; - - const action = if (args.len > 0) args[0] else "list"; - - if (std.mem.eql(u8, action, "list") or std.mem.eql(u8, action, "ls")) { - return worktree_prune.listWithAge(gpa, io, arena, out); - } - - if (std.mem.eql(u8, action, "merge")) { - if (args.len < 2) { - try out.writeAll("usage: graff worktree merge \n"); - return; - } - const name = args[1]; - const wt_path = try std.fmt.allocPrint(arena, ".graff/worktrees/{s}", .{name}); - const wt_branch = try std.fmt.allocPrint(arena, "worktree-{s}", .{name}); - - // Refuse to land into a dirty tree: the conflict-recovery below resets - // tracked files, which would eat uncommitted work. Untracked files (the - // worktrees, traces) are fine — reset --hard leaves them be. - if (worktree_prune.treeDirty(gpa, io)) { - try out.print("✗ your working tree has uncommitted changes — commit or stash them first, then `graff worktree merge {s}`\n", .{name}); - return; - } - - // 1) squash-merge the scratch branch into the current branch (staged, not committed). - const m = runCapped(gpa, io, &.{ "git", "merge", "--squash", wt_branch }, 1 << 16, 1 << 16, 60_000) catch { - try out.writeAll("✗ could not run git merge (is this a git repository?)\n"); - return; - }; - const merged = ranOk(m); - gpa.free(m.stdout); - gpa.free(m.stderr); - if (!merged) { - // Overlapping changes. A --squash merge leaves the index/worktree - // half-merged with no MERGE_HEAD to --abort, so restore the branch to - // clean ourselves (safe — we verified it was clean above) and leave - // the worktree intact for the user to land another way. - if (runCapped(gpa, io, &.{ "git", "reset", "--hard", "HEAD" }, 8192, 8192, 30_000)) |r| { - gpa.free(r.stdout); - gpa.free(r.stderr); - } else |_| {} - try out.print("✗ couldn't auto-land {s} — it overlaps changes already on this branch.\n current branch left clean, worktree intact. Land it first, or merge by hand: git merge {s}\n", .{ wt_branch, wt_branch }); - return; - } - - // 2) commit the squashed result as one clean commit on the current branch. - const cmsg = std.fmt.allocPrint(arena, "{s}: land worktree\n\n{s}", .{ name, codegraff_coauthor }) catch "land worktree"; - const c = runCapped(gpa, io, &.{ "git", "commit", "--no-verify", "-m", cmsg }, 8192, 8192, 30_000) catch { - try out.writeAll("✗ git commit failed — worktree left intact\n"); - return; - }; - const committed = ranOk(c); - gpa.free(c.stdout); - gpa.free(c.stderr); - if (!committed) { - try out.print("⚠ nothing to land from {s} (empty or already merged) — worktree left intact\n", .{wt_branch}); - return; - } - - // 3) clean up: remove the worktree dir, then delete its now-free branch. - if (runCapped(gpa, io, &.{ "git", "worktree", "remove", "--force", wt_path }, 8192, 8192, 30_000)) |r| { - gpa.free(r.stdout); - gpa.free(r.stderr); - } else |_| {} - if (runCapped(gpa, io, &.{ "git", "branch", "-D", wt_branch }, 8192, 8192, 30_000)) |r| { - gpa.free(r.stdout); - gpa.free(r.stderr); - } else |_| {} - - try out.print("✓ landed {s} → current branch as one commit, removed the worktree\n", .{wt_branch}); - return; - } - - if (std.mem.eql(u8, action, "remove") or std.mem.eql(u8, action, "rm")) { - if (args.len < 2) { - try out.writeAll("usage: graff worktree remove \n"); - return; - } - const name = args[1]; - const wt_path = try std.fmt.allocPrint(arena, ".graff/worktrees/{s}", .{name}); - const wt_branch = try std.fmt.allocPrint(arena, "worktree-{s}", .{name}); - // --force: discard any uncommitted scratch work — the whole point of - // `remove` is to throw away an abandoned tab (#112). - const rm = runCapped(gpa, io, &.{ "git", "worktree", "remove", "--force", wt_path }, 8192, 8192, 30_000) catch { - try out.print("✗ could not remove {s} (not a git repository, or no such worktree)\n", .{wt_path}); - return; - }; - defer { - gpa.free(rm.stdout); - gpa.free(rm.stderr); - } - if (!ranOk(rm)) { - try out.print("✗ couldn't remove {s}: {s}", .{ wt_path, rm.stderr }); - return; - } - // -D (force) so an unmerged scratch branch is still deleted. - if (runCapped(gpa, io, &.{ "git", "branch", "-D", wt_branch }, 8192, 8192, 30_000)) |r| { - gpa.free(r.stdout); - gpa.free(r.stderr); - } else |_| {} - try out.print("✓ removed {s} and branch {s}\n", .{ wt_path, wt_branch }); - return; - } - - if (std.mem.eql(u8, action, "prune")) { - // Drops git's registrations for worktrees whose dirs were deleted out of - // band, and with `older-than ` the stale DIRECTORIES too (#112). - return worktree_prune.pruneCommand(gpa, io, arena, out, args[1..]); - } - - try out.print("unknown worktree command '{s}' — use: graff worktree list | merge | remove | prune [older-than ]\n", .{action}); -} - const Job = struct { // session-global; pump drains pipes; survives Esc id: u32, cmd: []u8, child: std.process.Child, + // #199 idle lifecycle + ownership record (job_idle.zig, job_registry.zig) + cwd: ?[]u8 = null, // owned copy: /jobs restart reruns in the same place + started_ms: i64 = 0, // unix ms, for age columns and the record + last_active_ms: i64 = 0, // awake ms: last output byte, read, wait tick, or pin + pinned: bool = false, // /jobs keep: no idle stop, retained at session end + idle_warned: bool = false, + stopped_idle: bool = false, // the idle policy killed it, not bash_kill + detach: bool = false, // session end kept it: the pump exits without a kill buf: std.ArrayList(u8) = .empty, cursor: usize = 0, exit_code: ?u8 = null, @@ -235,6 +89,10 @@ const Job = struct { // session-global; pump drains pipes; survives Esc pub const job_unread_cap = 256 * 1024; const job_wait = @import("job_wait.zig"); const job_notify = @import("job_notify.zig"); +const job_idle = @import("job_idle.zig"); // #199 +const job_registry = @import("job_registry.zig"); // #199 +const proc_identity = @import("proc_identity.zig"); +const util = @import("util.zig"); const tool_pulse = @import("tool_pulse.zig"); // silence heartbeat during waitForeground /// POSIX process groups; windows/wasi have none, so the group kills below and @@ -258,10 +116,11 @@ pub var g_jobs: Jobs = .{}; /// Drain whatever the MultiReader has buffered into the job's output buffer, /// dropping the oldest *unread* bytes past the cap (a chatty server must not /// grow memory unboundedly between bash_output polls). Caller holds the mutex. -fn jobDrain(job: *Job, gpa: Allocator, readers: []const *Io.Reader) void { +fn jobDrain(job: *Job, gpa: Allocator, readers: []const *Io.Reader, now_ms: i64) void { for (readers, 0..) |r, i| { const b = r.buffered(); if (b.len == 0) continue; + job.last_active_ms = now_ms; // output is activity (#199) if (job.stream) |emit| emit(job.stream_ctx, @intCast(i), b); job.buf.appendSlice(gpa, b) catch {}; r.toss(b.len); @@ -282,29 +141,55 @@ fn jobPump(job: *Job, gpa: Allocator, io: Io) void { mr.init(gpa, io, mrb.toStreams(), &.{ job.child.stdout.?, job.child.stderr.? }); defer mr.deinit(); const readers = [2]*Io.Reader{ mr.reader(0), mr.reader(1) }; + // The leader's pid, taken now: kill/wait reap the child and clear `id`. + const pid: i32 = if (comptime posix_groups) (job.child.id orelse 0) else 0; var killed = false; + var detached = false; loop: while (true) { mr.fill(64, .{ .duration = .{ .raw = .fromMilliseconds(200), .clock = .awake } }) catch |err| switch (err) { error.EndOfStream => break :loop, error.Timeout => {}, // poll tick: check for a kill request else => break :loop, }; + const now = nowMs(io); g_jobs.mutex.lockUncancelable(io); - jobDrain(job, gpa, &readers); + jobDrain(job, gpa, &readers, now); + // #199: silence is the idle clock — no bytes, no read, no pin. + const idle_ms: u64 = @intCast(@max(now - job.last_active_ms, 0)); + var warn = false; + if (!job.kill_requested and !job.detach) { + switch (job_idle.verdict(idle_ms, job.idle_warned, job.pinned)) { + .none => {}, + .warn => { + job.idle_warned = true; + warn = true; + }, + .stop => { + job.kill_requested = true; + job.stopped_idle = true; + }, + } + } killed = job.kill_requested; + detached = job.detach; g_jobs.mutex.unlock(io); - if (killed) break :loop; + if (warn) job_idle.warn(io, job.id, idle_ms, job.cmd); + if (killed or detached) break :loop; } g_jobs.mutex.lockUncancelable(io); - jobDrain(job, gpa, &readers); // final drain of anything left at EOF/kill + jobDrain(job, gpa, &readers, nowMs(io)); // final drain of anything left at EOF/kill killed = killed or job.kill_requested; + detached = detached or job.detach; g_jobs.mutex.unlock(io); var code: ?u8 = null; - if (killed) { + if (detached) { + // Session end kept this pinned job: no kill, no wait. Its pipes now + // drain into a detached cat and its record says retained (#199). + } else if (killed) { // #198: take the whole process group down first — a job's grandchildren // (ssh, xcodebuild, codedb) survive a bare child.kill and are then // reparented to init, where they sleep on for days. - if (comptime posix_groups) if (job.child.id) |pid| std.posix.kill(-pid, .KILL) catch {}; + if (comptime posix_groups) if (pid != 0) std.posix.kill(-pid, .KILL) catch {}; job.child.kill(io); // also reaps (wait would assert afterwards) } else if (job.child.wait(io)) |term| { code = switch (term) { @@ -314,13 +199,83 @@ fn jobPump(job: *Job, gpa: Allocator, io: Io) void { } else |_| {} g_jobs.mutex.lockUncancelable(io); job.exit_code = code; - job.killed = killed; + job.killed = killed and !detached; job.done = true; const id = job.id; const cmd = job.cmd; const quiet = job.quiet; + const idle = job.stopped_idle; g_jobs.mutex.unlock(io); - if (!quiet) job_notify.record(io, id, code, killed, cmd); + if (detached) return; + if (pid != 0) job_registry.forget(io, job_registry.home, pid); + if (!quiet) job_notify.record(io, id, code, killed, cmd, idle); +} + +fn nowMs(io: Io) i64 { + return @intCast(@divTrunc(Io.Timestamp.now(io, .awake).nanoseconds, std.time.ns_per_ms)); +} + +/// The leader's start identity (#413), so a recycled pid is never mistaken +/// for the job. 0 where the platform has no source. +fn startIdOf(io: Io, pid: i32) u64 { + return switch (proc_identity.probe(io, pid)) { + .id => |v| v, + else => 0, + }; +} + +/// Caller holds the mutex (or owns the job outright). +fn recordOf(io: Io, job: *Job) job_registry.Record { + const pid: i32 = if (comptime posix_groups) (job.child.id orelse 0) else 0; + return .{ + .pid = pid, + .start_id = startIdOf(io, pid), + .owner_pid = proc_identity.selfPid(), + .owner_start_id = proc_identity.selfStartId(io), + .cmd = job.cmd, + .cwd = job.cwd orelse "", + .started_ms = job.started_ms, + .pinned = job.pinned, + }; +} + +/// /jobs keep|unkeep (#199): exempt from the idle stop, retained at session +/// end. Null for an unknown id, false for one that already finished. +pub fn setPinned(io: Io, id: u32, pinned: bool) ?bool { + g_jobs.mutex.lockUncancelable(io); + defer g_jobs.mutex.unlock(io); + const job = g_jobs.find(id) orelse return null; + if (job.done) return false; + job.pinned = pinned; + job.last_active_ms = nowMs(io); + if (comptime posix_groups) job_registry.write(io, job_registry.home, recordOf(io, job)); + return true; +} + +/// /jobs restart (#199): rerun a finished job's command in its cwd, as a new +/// job. The finished record stays listed until reaped. +pub fn restartJob(gpa: Allocator, io: Io, id: u32) !*Job { + var cmd: []const u8 = ""; + var cwd: ?[]const u8 = null; + { + g_jobs.mutex.lockUncancelable(io); + defer g_jobs.mutex.unlock(io); + const job = g_jobs.find(id) orelse return error.NoSuchJob; + if (!job.done) return error.StillRunning; + cmd = job.cmd; + cwd = job.cwd; + } + return spawnJobOpts(gpa, io, cmd, .{ .cwd = cwd }); +} + +/// Session end for a pinned job (#199): hand its pipes to a detached drainer +/// and keep its record, instead of killing it. False = kill it after all. +fn retainAtExit(io: Io, job: *Job) bool { + if (comptime !posix_groups) return false; + const rec = recordOf(io, job); + if (!job_registry.retain(io, job_registry.home, rec, job.child.stdout, job.child.stderr)) return false; + std.debug.print("kept alive: job {d} (pid {d}) {s} — `graff servers` lists it; `graff servers stop {d}` ends it\n", .{ job.id, rec.pid, job.cmd, rec.pid }); + return true; } pub fn shellArgv(cmd: []const u8) [3][]const u8 { // /bin/sh -c, or cmd.exe /c on Windows @@ -360,7 +315,18 @@ pub fn spawnJobOpts(gpa: Allocator, io: Io, cmd: []const u8, opts: SpawnOpts) !* child.kill(io); return e; }; - job.* = .{ .id = 0, .cmd = cmd_copy, .child = child, .stream = opts.stream, .stream_ctx = opts.stream_ctx, .quiet = opts.quiet }; + const cwd_copy: ?[]u8 = if (opts.cwd) |c| (gpa.dupe(u8, c) catch null) else null; + job.* = .{ + .id = 0, + .cmd = cmd_copy, + .child = child, + .stream = opts.stream, + .stream_ctx = opts.stream_ctx, + .quiet = opts.quiet, + .cwd = cwd_copy, + .started_ms = util.unixMs(io), + .last_active_ms = nowMs(io), + }; g_jobs.mutex.lockUncancelable(io); job.id = g_jobs.next_id; g_jobs.next_id += 1; @@ -371,6 +337,7 @@ pub fn spawnJobOpts(gpa: Allocator, io: Io, cmd: []const u8, opts: SpawnOpts) !* g_jobs.mutex.unlock(io); if (!appended) { job.child.kill(io); + if (job.cwd) |c| gpa.free(c); gpa.free(job.cmd); gpa.destroy(job); return error.OutOfMemory; @@ -385,10 +352,13 @@ pub fn spawnJobOpts(gpa: Allocator, io: Io, cmd: []const u8, opts: SpawnOpts) !* } g_jobs.mutex.unlock(io); job.child.kill(io); + if (job.cwd) |c| gpa.free(c); gpa.free(job.cmd); gpa.destroy(job); return e; }; + // #199: the ownership record outlives a graff that dies without its defers. + if (comptime posix_groups) job_registry.write(io, job_registry.home, recordOf(io, job)); return job; } @@ -405,6 +375,7 @@ pub fn jobOutput(gpa: Allocator, io: Io, id: u32, wait_ms: u64) !ToolOutput { g_jobs.mutex.unlock(io); return .{ .text = try std.fmt.allocPrint(gpa, "no background job {d} — it may never have started; /jobs lists them", .{id}), .is_error = true }; }; + job.last_active_ms = nowMs(io); // a read or a blocking wait is activity (#199) const fresh = job.buf.items[job.cursor..]; if (job.done or interrupted or waited >= deadline) { var aw: Io.Writer.Allocating = .init(gpa); @@ -412,6 +383,8 @@ pub fn jobOutput(gpa: Allocator, io: Io, id: u32, wait_ms: u64) !ToolOutput { const w = &aw.writer; if (!job.done) { try job_notify.printRunning(w, id, waited, interrupted); + } else if (job.stopped_idle) { + try job_idle.printStopped(w, id, job_idle.policy.stop_ms); } else if (job.killed) { try w.print("[job {d}: killed]", .{id}); } else if (job.exit_code) |c| { @@ -499,6 +472,7 @@ fn takeUnread(gpa: Allocator, job: *Job) error{OutOfMemory}!struct { []u8, bool fn freeJob(gpa: Allocator, io: Io, job: *Job) void { job.future.await(io); job.buf.deinit(gpa); + if (job.cwd) |c| gpa.free(c); gpa.free(job.cmd); gpa.destroy(job); } @@ -530,6 +504,7 @@ pub fn waitForeground(gpa: Allocator, io: Io, id: u32, wait_ms: u64) !FgWait { g_jobs.mutex.unlock(io); return error.NoSuchJob; }; + job.last_active_ms = nowMs(io); // the foreground wait is activity (#199) if (job.done) { const pair = takeUnread(gpa, job) catch { g_jobs.mutex.unlock(io); @@ -583,7 +558,11 @@ pub fn jobsReap(gpa: Allocator, io: Io) void { g_jobs.mutex.unlock(io); return; }; - for (jobs) |job| job.kill_requested = true; + for (jobs) |job| { + // #199: a pinned, still-running job is kept, not killed — its pipes + // go to a detached drainer, its record stays for `graff servers`. + if (job.pinned and !job.done and retainAtExit(io, job)) job.detach = true else job.kill_requested = true; + } g_jobs.mutex.unlock(io); for (jobs) |job| freeJob(gpa, io, job); gpa.free(jobs); @@ -591,8 +570,8 @@ pub fn jobsReap(gpa: Allocator, io: Io) void { } test { // split-out modules: unreferenced, their tests silently never run - _ = worktree_prune; + _ = worktree_cmd; _ = @import("worktree_lease.zig"); - _ = .{ job_wait, job_notify }; + _ = .{ job_wait, job_notify, job_idle, job_registry }; _ = @import("jobs_tests.zig"); } diff --git a/src/jobs_tests.zig b/src/jobs_tests.zig index e42de30a..92e2e8da 100644 --- a/src/jobs_tests.zig +++ b/src/jobs_tests.zig @@ -5,6 +5,9 @@ const std = @import("std"); const builtin = @import("builtin"); const jobs = @import("jobs.zig"); +const job_idle = @import("job_idle.zig"); +const job_registry = @import("job_registry.zig"); +const proc_identity = @import("proc_identity.zig"); test "foreground tool subprocesses own their process group (#266, #198)" { const inherited = jobs.toolRunOptions(null); @@ -128,3 +131,110 @@ test "#620: a short foreground command finishes as done, not a job" { try std.testing.expectEqual(@as(?u8, 0), waited.done.exit_code); try std.testing.expect(std.mem.indexOf(u8, waited.done.output, "hi") != null); } + +test "#199: a silent, unread job is stopped after the idle budget and can be restarted" { + if (builtin.os.tag == .windows or builtin.os.tag == .wasi) return error.SkipZigTest; + jobs.g_jobs = .{}; + const gpa = std.testing.allocator; + const io = std.testing.io; + const saved = job_idle.policy; + defer job_idle.policy = saved; + job_idle.policy = .{ .warn_ms = 100, .stop_ms = 400 }; + const id = (try jobs.spawnJob(gpa, io, "sleep 30")).id; + defer jobs.jobsReap(gpa, io); + io.sleep(.fromMilliseconds(1_500), .awake) catch {}; + const snap = try jobs.jobOutput(gpa, io, id, 0); + defer gpa.free(snap.text); + try std.testing.expect(std.mem.indexOf(u8, snap.text, "stopped after") != null); + try std.testing.expect(std.mem.indexOf(u8, snap.text, "/jobs keep") != null); + // The command survives: a restart is a new job, running, in the same place. + const again = try jobs.restartJob(gpa, io, id); + try std.testing.expect(again.id != id); + const snap2 = try jobs.jobOutput(gpa, io, again.id, 0); + defer gpa.free(snap2.text); + try std.testing.expect(std.mem.indexOf(u8, snap2.text, "running") != null); + try std.testing.expectError(error.StillRunning, jobs.restartJob(gpa, io, again.id)); + try std.testing.expectError(error.NoSuchJob, jobs.restartJob(gpa, io, 9999)); +} + +test "#199: output keeps a job alive past the budget; a pinned job is never idle-stopped" { + if (builtin.os.tag == .windows or builtin.os.tag == .wasi) return error.SkipZigTest; + jobs.g_jobs = .{}; + const gpa = std.testing.allocator; + const io = std.testing.io; + const saved = job_idle.policy; + defer job_idle.policy = saved; + job_idle.policy = .{ .warn_ms = 100, .stop_ms = 300 }; + const chatty = (try jobs.spawnJob(gpa, io, "i=0; while [ $i -lt 15 ]; do echo tick; sleep 0.1; i=$((i+1)); done")).id; + const pinned = (try jobs.spawnJob(gpa, io, "sleep 4")).id; + defer jobs.jobsReap(gpa, io); + try std.testing.expectEqual(@as(?bool, true), jobs.setPinned(io, pinned, true)); + try std.testing.expectEqual(@as(?bool, null), jobs.setPinned(io, 9999, true)); + io.sleep(.fromMilliseconds(1_000), .awake) catch {}; + for ([_]u32{ chatty, pinned }) |id| { + const snap = try jobs.jobOutput(gpa, io, id, 0); + defer gpa.free(snap.text); + try std.testing.expect(std.mem.indexOf(u8, snap.text, "running") != null); + } + _ = jobs.setPinned(io, pinned, false); // so the reap below kills it instead of retaining it +} + +test "#199: a spawn writes an ownership record with the leader's identity; reaping removes it" { + if (builtin.os.tag == .windows or builtin.os.tag == .wasi) return error.SkipZigTest; + jobs.g_jobs = .{}; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(io, &buf); + const saved_home = job_registry.home; + defer job_registry.home = saved_home; + job_registry.home = buf[0..n]; + + const job = try jobs.spawnJob(gpa, io, "sleep 5"); + const pid = job.child.id.?; + const recs = job_registry.list(io, arena, job_registry.home); + try std.testing.expectEqual(@as(usize, 1), recs.len); + try std.testing.expectEqual(pid, recs[0].pid); + try std.testing.expectEqualStrings("sleep 5", recs[0].cmd); + try std.testing.expectEqual(proc_identity.selfPid(), recs[0].owner_pid); + try std.testing.expect(job_registry.ownerAlive(io, recs[0])); + try std.testing.expectEqual(job_registry.State.running, job_registry.state(io, recs[0])); + jobs.jobsReap(gpa, io); + try std.testing.expectEqual(@as(usize, 0), job_registry.list(io, arena, job_registry.home).len); +} + +test "#199: a pinned job is retained at session end — record kept, tree alive, then stoppable by pid" { + if (builtin.os.tag == .windows or builtin.os.tag == .wasi) return error.SkipZigTest; + jobs.g_jobs = .{}; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(io, &buf); + const saved_home = job_registry.home; + defer job_registry.home = saved_home; + job_registry.home = buf[0..n]; + + const job = try jobs.spawnJob(gpa, io, "sleep 30"); + const pid = job.child.id.?; + try std.testing.expectEqual(@as(?bool, true), jobs.setPinned(io, job.id, true)); + jobs.jobsReap(gpa, io); // session end: pinned → retained, not killed + const recs = job_registry.list(io, arena, job_registry.home); + try std.testing.expectEqual(@as(usize, 1), recs.len); + try std.testing.expect(recs[0].retained); + try std.testing.expectEqual(pid, recs[0].pid); + try std.testing.expectEqual(job_registry.State.running, job_registry.state(io, recs[0])); + // A later `graff servers stop `: verified, then the whole group goes. + try std.testing.expectEqual(job_registry.StopResult.stopped, job_registry.stopTree(io, recs[0])); + job_registry.forget(io, job_registry.home, pid); + try std.testing.expectEqual(@as(usize, 0), job_registry.list(io, arena, job_registry.home).len); +} diff --git a/src/json_inbox.zig b/src/json_inbox.zig index f175b455..90f40a3f 100644 --- a/src/json_inbox.zig +++ b/src/json_inbox.zig @@ -6,6 +6,7 @@ const Io = std.Io; const Allocator = std.mem.Allocator; const Value = std.json.Value; const Agent = @import("agent.zig").Agent; +const cancel_source = @import("cancel_source.zig"); // #728 const cancelled_answer = "{\"type\":\"answer\",\"cancelled\":true}"; @@ -127,7 +128,7 @@ fn readerTask() void { var queued_turn = false; for (lines.items) |queued| queued_turn = queued_turn or isTurn(gpa, queued); if (active) { - Agent.esc_cancel.store(true, .release); + cancel_source.cancel(.json_cancel); cancel_epoch +%= 1; } else if (pending) { pending_cancelled = true; diff --git a/src/mainloop.zig b/src/mainloop.zig index 01d54d80..b30089b4 100644 --- a/src/mainloop.zig +++ b/src/mainloop.zig @@ -7,6 +7,7 @@ const Allocator = std.mem.Allocator; const main_mod = @import("main.zig"); const util = @import("util.zig"); +const cancel_source = @import("cancel_source.zig"); // #728: who cancelled, for the marker const agent_mod = @import("agent.zig"); const provider_mod = @import("provider.zig"); const ansi = @import("ansi.zig"); @@ -427,19 +428,19 @@ pub fn run(ctx: *Ctx) !void { } const final_text = turn_result catch |err| switch (err) { error.Interrupted => { - // Preserve streamed text and mark the saved assistant turn incomplete. + // Preserve streamed text and mark the saved assistant turn incomplete. #728: an unsourced cancel is the harness's, not the user's. + const src = cancel_source.take(ctx.root.tracer); const partial = std.mem.trim(u8, ctx.root.partial_text.items, " \t\r\n"); const marker: []const u8 = if (partial.len > 0) - try std.fmt.allocPrint(ctx.arena, "{s}\n\n[response interrupted by user]", .{partial}) + try std.fmt.allocPrint(ctx.arena, "{s}\n\n{s}", .{ partial, cancel_source.marker(src) }) else - "[response interrupted by user]"; + cancel_source.marker(src); try ctx.root.messages.append(try messages.textMessage(ctx.arena, "assistant", marker)); - const int_msg: []const u8 = if (main_mod.g_force_interrupt) "✗ interrupted (force)" else "✗ interrupted (esc)"; main_mod.g_force_interrupt = false; if (main_mod.json_mode) { - ctx.root.emit(.{ .type = "error", .message = "turn cancelled" }); + ctx.root.emit(.{ .type = "error", .message = cancel_source.jsonMessage(src) }); } else { - try ctx.out.print("{s}{s}{s}\n", .{ style.yellow, int_msg, style.reset }); + try ctx.out.print("{s}{s}{s}\n", .{ style.yellow, cancel_source.chrome(src), style.reset }); try ctx.out.flush(); } session.saveSession(ctx.root, ctx.arena, ctx.root.session_name) catch {}; diff --git a/src/repl_glue.zig b/src/repl_glue.zig index 4b6473ad..fd22b9e8 100644 --- a/src/repl_glue.zig +++ b/src/repl_glue.zig @@ -364,7 +364,7 @@ pub fn replModelPick(ctx_ptr: ?*anyopaque, gpa: Allocator, provider_id: []const /// queue. Cross-thread safe (atomic) — the same signal the TTY esc-watch uses. pub fn replCancelCb(ctx_ptr: ?*anyopaque) void { _ = ctx_ptr; - Agent.esc_cancel.store(true, .release); + @import("cancel_source.zig").cancel(.ui_cancel); // #728 } pub const SteerEntry = struct { text: []const u8, force: bool }; diff --git a/src/schema.zig b/src/schema.zig index 290da00b..940551ba 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -53,7 +53,7 @@ const empty_schema = pub const base_specs = [_]ToolSpec{ .{ .name = "bash", - .desc = "Run a shell command via /bin/sh -c in the current working directory. Returns stdout, stderr, and the exit code. A user-cancelled command reports cancelled (its whole local process group is killed; a remote process started over ssh may survive on the remote host). Foreground commands that are still running after 120s (or timeout ms) are moved to the background and return a job id — the process keeps running. For long-running commands set run_in_background true to skip the wait. You are notified on completion — do not poll. bash_output(wait_ms>0) blocks until exit (up to 10h); omit wait_ms for a snapshot. Stop it with bash_kill.", + .desc = "Run a shell command via /bin/sh -c in the current working directory. Returns stdout, stderr, and the exit code. A user-cancelled command reports cancelled (its whole local process group is killed; a remote process started over ssh may survive on the remote host). Foreground commands that are still running after 120s (or timeout ms) are moved to the background and return a job id — the process keeps running. For long-running commands set run_in_background true to skip the wait. You are notified on completion — do not poll. bash_output(wait_ms>0) blocks until exit (up to 10h); omit wait_ms for a snapshot. Stop it with bash_kill. A background job that writes nothing and is read by nobody for 2 hours is stopped for inactivity and you are told; rerun it only if it is still needed (the user pins a long-lived server with /jobs keep).", .schema = \\{"type": "object", "properties": {"command": {"type": "string", "description": "Shell command to execute"}, "timeout": {"type": "integer", "description": "Optional foreground wait in milliseconds before auto-background (default 120000, max 36000000). 0 uses the default. Ignored when run_in_background is true."}, "run_in_background": {"type": "boolean", "description": "Start as a background job and return its id immediately instead of waiting (default false)"}}, "required": ["command"]} , diff --git a/src/servers_cmd.zig b/src/servers_cmd.zig new file mode 100644 index 00000000..4670f1ff --- /dev/null +++ b/src/servers_cmd.zig @@ -0,0 +1,79 @@ +//! `graff servers [stop |prune]` (#199): every background server graff +//! started — this session's and earlier ones' — from the ownership records +//! in ~/.codegraff/jobs. `stop ` ends a tree only after the leader's +//! start identity matches its record, so a recycled pid is never signalled; +//! `prune` drops records whose process is gone. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const job_registry = @import("job_registry.zig"); +const tool_pulse = @import("tool_pulse.zig"); +const util = @import("util.zig"); + +pub fn command(gpa: Allocator, io: Io, arena: Allocator, args: []const []const u8) !void { + var buf: [4096]u8 = undefined; + var w = Io.File.stdout().writer(io, &buf); + const out = &w.interface; + defer out.flush() catch {}; + const home = job_registry.home; + if (home.len == 0) return out.writeAll("graff servers: no HOME, so no job records\n"); + const action = if (args.len > 0) args[0] else "list"; + const recs = job_registry.list(io, arena, home); + + if (std.mem.eql(u8, action, "list") or std.mem.eql(u8, action, "ls")) { + if (recs.len == 0) return out.writeAll("no background servers on record — graff writes one per background job it starts (~/.codegraff/jobs)\n"); + const now = util.unixMs(io); + try out.writeAll("pid state age owner port(s) command\n"); + for (recs) |rec| { + const st = job_registry.state(io, rec); + var abuf: [16]u8 = undefined; + const age = tool_pulse.formatElapsed(&abuf, @intCast(@max(now - rec.started_ms, 0))); + const owner: []const u8 = if (job_registry.ownerAlive(io, rec)) "live" else "gone"; + const ports = if (st == .running) job_registry.listenPorts(gpa, io, arena, rec.pid) else ""; + const state_s: []const u8 = switch (st) { + .running => if (rec.retained) "retained" else if (rec.pinned) "pinned" else "running", + .gone => "gone", + .unverifiable => "unknown", + }; + try out.print("{d:<8} {s:<9} {s:<8} {s:<6} {s:<21} {s}", .{ rec.pid, state_s, age, owner, ports, util.utf8Prefix(rec.cmd, 60) }); + if (rec.cwd.len > 0) try out.print(" ({s})", .{rec.cwd}); + try out.writeAll("\n"); + } + return out.writeAll("graff servers stop ends one (its whole process group); graff servers prune drops records of dead ones\n"); + } + + if (std.mem.eql(u8, action, "stop")) { + if (args.len < 2) return out.writeAll("usage: graff servers stop \n"); + const pid = std.fmt.parseInt(i32, args[1], 10) catch return out.print("graff servers stop: '{s}' is not a pid\n", .{args[1]}); + for (recs) |rec| { + if (rec.pid != pid) continue; + switch (job_registry.stopTree(io, rec)) { + .stopped => { + job_registry.forget(io, home, pid); + try out.print("✓ stopped pid {d} and its process group: {s}\n", .{ pid, rec.cmd }); + }, + .gone => { + job_registry.forget(io, home, pid); + try out.print("pid {d} is already gone (record dropped)\n", .{pid}); + }, + .unverifiable => try out.print("✗ pid {d} could not be verified as the job graff started — not touched\n", .{pid}), + .unsupported => try out.writeAll("✗ graff servers stop is not available on this platform\n"), + } + return; + } + return out.print("no record of pid {d} — only jobs graff started can be stopped here; graff servers lists them\n", .{pid}); + } + + if (std.mem.eql(u8, action, "prune")) { + var dropped: usize = 0; + for (recs) |rec| { + if (job_registry.state(io, rec) != .gone) continue; + job_registry.forget(io, home, rec.pid); + dropped += 1; + } + return out.print("✓ dropped {d} record(s) of servers that are gone\n", .{dropped}); + } + + try out.print("unknown servers command '{s}' — use: graff servers [list | stop | prune]\n", .{action}); +} diff --git a/src/session_settings.zig b/src/session_settings.zig index bccb4adb..6e76dd9b 100644 --- a/src/session_settings.zig +++ b/src/session_settings.zig @@ -19,6 +19,7 @@ const agent_mod = @import("agent.zig"); const http = @import("http.zig"); const http_stall = @import("http_stall.zig"); const plugins = @import("plugins.zig"); +const job_idle = @import("job_idle.zig"); // #199: GRAFF_JOB_IDLE_WARN_MINS / GRAFF_JOB_IDLE_STOP_MINS const ws = @import("ws.zig"); const agent_ws = @import("agent_ws.zig"); // codex_ws_idle_ms override (#codex-ws) const agent_request = @import("agent_request.zig"); // GRAFF_REQ_STATS → g_req_stats (token-diet measurement) @@ -66,6 +67,7 @@ pub fn applyEnvKnobs(arena: Allocator, environ_map: anytype) !void { main_mod.g_path_env = try arena.dupe(u8, environ_map.get("PATH") orelse ""); plugins.applyEnv(environ_map); main_mod.g_codedb_guard = environ_map.get("GRAFF_NO_CODEDB_GUARD") == null; // issue #626 guard, opt-out via env + job_idle.applyEnv(environ_map); // #199: background-job idle warn/stop, minutes (0 = off) main_mod.g_force_stall_once = environ_map.get("GRAFF_FORCE_STALL_ONCE") != null; // #134 test seam main_mod.g_force_drop_once = environ_map.get("GRAFF_FORCE_DROP_ONCE") != null; // #132/#133 test seam main_mod.g_force_stall_always = environ_map.get("GRAFF_FORCE_STALL_ALWAYS") != null; // #56 test seam (exhaust the reconnect budget) diff --git a/src/session_settings_tests.zig b/src/session_settings_tests.zig index 4b65c4ff..ae1286f7 100644 --- a/src/session_settings_tests.zig +++ b/src/session_settings_tests.zig @@ -25,6 +25,7 @@ const ws = @import("ws.zig"); const agent_ws = @import("agent_ws.zig"); const plugins = @import("plugins.zig"); const mcp_schema_gate = @import("mcp_schema_gate.zig"); +const job_idle = @import("job_idle.zig"); const Knob = struct { name: []const u8, value: []const u8 }; @@ -59,6 +60,8 @@ const knobs = [_]Knob{ .{ .name = "GRAFF_NO_STABLE_CATALOG", .value = "1" }, .{ .name = "GRAFF_VERCEL_URL", .value = "https://ai-gateway.vercel.sh/v1/chat/completions" }, .{ .name = "GRAFF_XAI_X_SEARCH", .value = "0" }, + .{ .name = "GRAFF_JOB_IDLE_WARN_MINS", .value = "3" }, + .{ .name = "GRAFF_JOB_IDLE_STOP_MINS", .value = "0" }, }; /// A stand-in for the process environment that records which names were asked @@ -109,6 +112,7 @@ const Saved = struct { plugins_off: bool, stable_catalog: bool, x_search: bool, + job_idle: job_idle.Policy, fn capture() Saved { return .{ @@ -138,6 +142,7 @@ const Saved = struct { .plugins_off = plugins.disabled, .stable_catalog = mcp_schema_gate.g_stable_catalog, .x_search = @import("xai_hosted.zig").enabled, + .job_idle = job_idle.policy, }; } @@ -169,6 +174,7 @@ const Saved = struct { plugins.disabled = s.plugins_off; mcp_schema_gate.g_stable_catalog = s.stable_catalog; @import("xai_hosted.zig").enabled = s.x_search; + job_idle.policy = s.job_idle; } }; @@ -213,11 +219,14 @@ test "applyEnvKnobs actually applies the values it reads" { tool_handle.threshold_bytes = tool_handle.default_threshold_bytes; plugins.disabled = false; mcp_schema_gate.g_stable_catalog = false; + job_idle.policy = .{}; @import("xai_hosted.zig").enabled = true; @import("native_fold.zig").resetContextKnob(); var asked: [knobs.len]bool = @splat(false); try session_settings.applyEnvKnobs(arena_state.allocator(), RecordingEnv{ .asked = &asked }); + try std.testing.expectEqual(@as(u64, 3 * std.time.ms_per_min), job_idle.policy.warn_ms); // #199 + try std.testing.expectEqual(@as(u64, 0), job_idle.policy.stop_ms); try std.testing.expect(!main_mod.g_codedb_guard); // present ⇒ guard OFF try std.testing.expect(main_mod.g_force_stall_once); diff --git a/src/skill_docs.zig b/src/skill_docs.zig index 84cab771..1ec1196b 100644 --- a/src/skill_docs.zig +++ b/src/skill_docs.zig @@ -130,7 +130,7 @@ pub const builtins = blk: { pub var g_skills: []const Skill = &builtins; /// Resolved HOME for the personal tier, so execSkill's rescan finds the same /// set the startup scan did. -var g_home: ?[]const u8 = null; +pub var g_home: ?[]const u8 = null; // pub: split-out tests save/restore it pub fn load(io: Io, arena: Allocator, home: ?[]const u8) []const Skill { g_home = home; @@ -215,7 +215,7 @@ fn loadDir(io: Io, arena: Allocator, list: *std.ArrayList(Skill), dir_path: []co fn mergeFile(io: Io, arena: Allocator, list: *std.ArrayList(Skill), dir_path: []const u8, path: []const u8, raw_name: []const u8, source: Source) void { // Prefix only: name + description for the prompt. The body stays on disk // until `skill name=` / render, same as ADR 0007 and OpenCode's catalog. - const data = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(head_cap)) catch return; + const data = readHead(io, arena, path) orelse return; // raw_name points into the directory iterator's buffer, which the next // entry overwrites — the parsed skill has to own it. const fallback = arena.dupe(u8, raw_name) catch return; @@ -231,6 +231,16 @@ fn mergeFile(io: Io, arena: Allocator, list: *std.ArrayList(Skill), dir_path: [] }); } +/// The first `head_cap` bytes of a SKILL.md, never the whole file. The +/// catalog used `readFileAlloc(.limited(head_cap))`, which fails with +/// StreamTooLong at the cap instead of truncating, so every playbook over +/// 8 KB silently vanished from the catalog and `skill ` could not load +/// it (#730). A truncating read keeps the catalog cheap and the skill present. +fn readHead(io: Io, arena: Allocator, path: []const u8) ?[]const u8 { + const buf = arena.alloc(u8, head_cap) catch return null; + return Io.Dir.cwd().readFile(io, path, buf) catch null; +} + /// Append, or replace the same-named skill from a lower tier. fn insert(arena: Allocator, list: *std.ArrayList(Skill), sk: Skill) void { for (list.items) |*existing| { @@ -572,3 +582,7 @@ test "load: catalog keeps name and desc; named skill reads the body from disk" { try std.testing.expect(!out.is_error); try std.testing.expect(std.mem.indexOf(u8, out.text, "CATALOG_MUST_NOT_KEEP_THIS_BODY") != null); } + +test { // split-out tests (file cap): unreferenced, they would silently never run + _ = @import("skill_docs_tests.zig"); +} diff --git a/src/skill_docs_tests.zig b/src/skill_docs_tests.zig new file mode 100644 index 00000000..60809c2d --- /dev/null +++ b/src/skill_docs_tests.zig @@ -0,0 +1,60 @@ +//! skill_docs tests that did not fit under the 600-line cap. Imported from +//! skill_docs.zig's `test {}` so they stay in the suite. + +const std = @import("std"); +const Io = std.Io; +const skill_docs = @import("skill_docs.zig"); + +// A playbook larger than the catalog's 8 KB head read must still be listed +// and load in full (#730). Only the catalog read is capped; the named load +// reads the whole file. +test "#730: a SKILL.md over 8 KB stays in the catalog and loads whole" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const io = std.testing.io; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(io, &buf); + const home = try std.fmt.allocPrint(arena, "{s}/home", .{buf[0..n]}); + const dir = try std.fmt.allocPrint(arena, "{s}/.harness/skills/graphify", .{home}); + try Io.Dir.cwd().createDirPath(io, dir); + + // 12 KB body: well past head_cap, with a marker only the full read sees. + var body: std.Io.Writer.Allocating = .init(arena); + try body.writer.writeAll("---\nname: graphify\ndescription: big playbook\n---\n\n"); + var i: usize = 0; + while (i < 200) : (i += 1) try body.writer.print("line {d}: the quick brown fox jumps over the lazy dog again\n", .{i}); + try body.writer.writeAll("TAIL_MARKER_PAST_THE_HEAD_CAP\n"); + const text = body.written(); + try std.testing.expect(text.len > 8 * 1024); + try Io.Dir.cwd().writeFile(io, .{ + .sub_path = try std.fmt.allocPrint(arena, "{s}/SKILL.md", .{dir}), + .data = text, + }); + + const prev_skills = skill_docs.g_skills; + const prev_home = skill_docs.g_home; + defer { + skill_docs.g_skills = prev_skills; + skill_docs.g_home = prev_home; // load() pins `home`, which this arena owns + } + const list = skill_docs.load(io, arena, home); + var found = false; + for (list) |sk| { + if (std.mem.eql(u8, sk.name, "graphify")) { + found = true; + try std.testing.expectEqualStrings("big playbook", sk.desc); + } + } + try std.testing.expect(found); + + skill_docs.g_skills = list; + var obj: std.json.ObjectMap = .empty; + try obj.put(arena, "name", .{ .string = "graphify" }); + const out = try skill_docs.execSkill(std.testing.allocator, io, .{ .object = obj }); + defer std.testing.allocator.free(out.text); + try std.testing.expect(!out.is_error); + try std.testing.expect(std.mem.indexOf(u8, out.text, "TAIL_MARKER_PAST_THE_HEAD_CAP") != null); +} diff --git a/src/startup.zig b/src/startup.zig index f2adc02e..5a5630aa 100644 --- a/src/startup.zig +++ b/src/startup.zig @@ -186,6 +186,7 @@ const mcp_cli = @import("mcp_cli.zig"); const learn_cli = @import("learn_cli.zig"); const cli = @import("cli.zig"); const jobs = @import("jobs.zig"); +const job_registry = @import("job_registry.zig"); // #199 const cube = @import("cube.zig"); const schema = @import("schema.zig"); const serve = @import("serve.zig"); @@ -231,6 +232,7 @@ pub fn runSubcommand(io: Io, gpa: Allocator, arena: Allocator, init: std.process // agents (the pre-compaction note, title, reflect) resolve the ONE file // the login flow writes instead of "/.kimi/...". oauth.initHome(keys_cli.homeEnv(init.environ_map) orelse ""); + job_registry.home = keys_cli.homeEnv(init.environ_map) orelse ""; // #199: ownership records for background jobs // #557: same pin for GRAFF_PRICES_PATH, before `graff models` is dispatched // below — the hydration points (router_catalog, models_cache) all sit well // under the last call that still holds the environment. @@ -327,6 +329,13 @@ pub fn runSubcommand(io: Io, gpa: Allocator, arena: Allocator, init: std.process return true; } + // `graff servers [stop |prune]`: background servers graff started — + // this session's or an earlier one's (#199). + if (flags.positionals.items.len > 0 and std.mem.eql(u8, flags.positionals.items[0], "servers")) { + try @import("servers_cmd.zig").command(gpa, io, arena, flags.positionals.items[1..]); + return true; + } + // `graff sandboxes [stop ]`: list the account's gateway sandboxes or // spin one down. Key resolution mirrors a normal run: CODEGRAFF_API_KEY // env first, else the `graff login` file via loadCodegraffKey. diff --git a/src/tui_acp.zig b/src/tui_acp.zig index ed3d74b4..ca925132 100644 --- a/src/tui_acp.zig +++ b/src/tui_acp.zig @@ -269,7 +269,7 @@ fn bindSession(ctx: *anyopaque, sid: []const u8) void { } fn syncEsc() void { - agent_mod.Agent.esc_cancel.store(true, .release); + @import("cancel_source.zig").cancel(.ui_cancel); // #728 } fn liveCancelled() bool { diff --git a/src/tui_launch.zig b/src/tui_launch.zig index 03976dba..d9a500c1 100644 --- a/src/tui_launch.zig +++ b/src/tui_launch.zig @@ -195,7 +195,7 @@ fn historyCb(ctx: ?*anyopaque, op: tui.HistoryOp) void { fn idleWakeCb(ctx: ?*anyopaque, buf: []u8) ?[]const u8 { const c: *repl_glue.ReplCtx = @ptrCast(@alignCast(ctx orelse return null)); - if (job_notify.takeWake(c.io, buf)) |t| return t; + if (job_notify.takeIdleWake(c.io, buf)) |t| return t; // an idle stop waits for a real step boundary (#199) if (schedule.takeWake(c.io, buf)) |t| return t; return channel_worker.takeWake(c.io, buf); } diff --git a/src/worktree_cmd.zig b/src/worktree_cmd.zig new file mode 100644 index 00000000..85fde54a --- /dev/null +++ b/src/worktree_cmd.zig @@ -0,0 +1,179 @@ +//! `graff worktree ` and the per-turn worktree +//! checkpoint commit for `-w` sessions. Moved out of jobs.zig (600-line cap) +//! when the background-job pool grew its idle lifecycle (#199); jobs.zig +//! re-exports both entry points, so callers are unchanged. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const root = @import("main.zig"); +const process_runner = @import("process_runner.zig"); +const runCapped = process_runner.runCapped; +const ranOk = process_runner.ranOk; + +/// Commit-message trailer that credits the harness assist. The commit AUTHOR +/// stays the user's own git identity (their GitHub account) — graff never +/// overrides GIT_AUTHOR_*; codegraff is recorded as a co-author instead, +/// mirroring how Claude Code attributes commits. +const codegraff_coauthor = "Co-Authored-By: Codegraff "; + +// #112 (list age column + `prune --older-than`) and #320 (canonical worktree +// identity) live in their own modules: jobs.zig is at the 600-line cap. +const worktree_prune = @import("worktree_prune.zig"); + +/// Per-turn checkpoint commit for `-w` sessions. The worktree branch is a +/// throwaway scratch branch, so committing every turn is free and gives durable +/// rewind points across restarts; `graff worktree merge` later --squashes the +/// whole trail into one clean commit. No-op outside a worktree or under +/// --no-autocommit. Best-effort: a clean tree (nothing to commit) or a missing +/// git identity just means no commit this turn, never a failed turn. --no-verify +/// so a slow or strict pre-commit hook can't block a checkpoint. +pub fn worktreeAutoCommit(gpa: Allocator, io: Io, msg: []const u8) void { + if (root.g_worktree_branch == null or !root.g_worktree_autocommit) return; + // Stage everything except graff's own runtime artifacts — trace/trajectory/ + // sessions/keys/MCP config must never ride into the squash-merge onto the + // user's branch. .gitignore hides these in the graff repo, but a *target* + // repo (the swarm's real use case) won't, so exclude them explicitly here. + const add = runCapped(gpa, io, &.{ + "git", "add", + "-A", "--", + ":(exclude).graff", ":(exclude).harness", + ":(exclude)harness.*.jsonl", ":(exclude)*.session.json", + ":(exclude).mcp.json", ":(exclude).simple-harness-*", + }, 4096, 4096, 30_000) catch return; + gpa.free(add.stdout); + gpa.free(add.stderr); + // Author stays the user's git identity; codegraff rides as a co-author trailer. + const full = std.fmt.allocPrint(gpa, "{s}\n\n{s}", .{ msg, codegraff_coauthor }) catch msg; + defer if (full.ptr != msg.ptr) gpa.free(full); + const c = runCapped(gpa, io, &.{ "git", "commit", "--no-verify", "-m", full }, 8192, 8192, 30_000) catch return; + gpa.free(c.stdout); + gpa.free(c.stderr); +} + +/// `graff worktree >` — manage the per-tab scratch worktrees +/// that `-w` creates. `list` shows them; `merge ` squash-merges +/// worktree- into the current branch as one clean commit, then removes the +/// worktree and deletes its branch. Run from the main checkout. +pub fn worktreeCommand(gpa: Allocator, io: Io, arena: Allocator, args: []const []const u8) !void { + var buf: [4096]u8 = undefined; + var w = Io.File.stdout().writer(io, &buf); + const out = &w.interface; + defer out.flush() catch {}; + + const action = if (args.len > 0) args[0] else "list"; + + if (std.mem.eql(u8, action, "list") or std.mem.eql(u8, action, "ls")) { + return worktree_prune.listWithAge(gpa, io, arena, out); + } + + if (std.mem.eql(u8, action, "merge")) { + if (args.len < 2) { + try out.writeAll("usage: graff worktree merge \n"); + return; + } + const name = args[1]; + const wt_path = try std.fmt.allocPrint(arena, ".graff/worktrees/{s}", .{name}); + const wt_branch = try std.fmt.allocPrint(arena, "worktree-{s}", .{name}); + + // Refuse to land into a dirty tree: the conflict-recovery below resets + // tracked files, which would eat uncommitted work. Untracked files (the + // worktrees, traces) are fine — reset --hard leaves them be. + if (worktree_prune.treeDirty(gpa, io)) { + try out.print("✗ your working tree has uncommitted changes — commit or stash them first, then `graff worktree merge {s}`\n", .{name}); + return; + } + + // 1) squash-merge the scratch branch into the current branch (staged, not committed). + const m = runCapped(gpa, io, &.{ "git", "merge", "--squash", wt_branch }, 1 << 16, 1 << 16, 60_000) catch { + try out.writeAll("✗ could not run git merge (is this a git repository?)\n"); + return; + }; + const merged = ranOk(m); + gpa.free(m.stdout); + gpa.free(m.stderr); + if (!merged) { + // Overlapping changes. A --squash merge leaves the index/worktree + // half-merged with no MERGE_HEAD to --abort, so restore the branch to + // clean ourselves (safe — we verified it was clean above) and leave + // the worktree intact for the user to land another way. + if (runCapped(gpa, io, &.{ "git", "reset", "--hard", "HEAD" }, 8192, 8192, 30_000)) |r| { + gpa.free(r.stdout); + gpa.free(r.stderr); + } else |_| {} + try out.print("✗ couldn't auto-land {s} — it overlaps changes already on this branch.\n current branch left clean, worktree intact. Land it first, or merge by hand: git merge {s}\n", .{ wt_branch, wt_branch }); + return; + } + + // 2) commit the squashed result as one clean commit on the current branch. + const cmsg = std.fmt.allocPrint(arena, "{s}: land worktree\n\n{s}", .{ name, codegraff_coauthor }) catch "land worktree"; + const c = runCapped(gpa, io, &.{ "git", "commit", "--no-verify", "-m", cmsg }, 8192, 8192, 30_000) catch { + try out.writeAll("✗ git commit failed — worktree left intact\n"); + return; + }; + const committed = ranOk(c); + gpa.free(c.stdout); + gpa.free(c.stderr); + if (!committed) { + try out.print("⚠ nothing to land from {s} (empty or already merged) — worktree left intact\n", .{wt_branch}); + return; + } + + // 3) clean up: remove the worktree dir, then delete its now-free branch. + if (runCapped(gpa, io, &.{ "git", "worktree", "remove", "--force", wt_path }, 8192, 8192, 30_000)) |r| { + gpa.free(r.stdout); + gpa.free(r.stderr); + } else |_| {} + if (runCapped(gpa, io, &.{ "git", "branch", "-D", wt_branch }, 8192, 8192, 30_000)) |r| { + gpa.free(r.stdout); + gpa.free(r.stderr); + } else |_| {} + + try out.print("✓ landed {s} → current branch as one commit, removed the worktree\n", .{wt_branch}); + return; + } + + if (std.mem.eql(u8, action, "remove") or std.mem.eql(u8, action, "rm")) { + if (args.len < 2) { + try out.writeAll("usage: graff worktree remove \n"); + return; + } + const name = args[1]; + const wt_path = try std.fmt.allocPrint(arena, ".graff/worktrees/{s}", .{name}); + const wt_branch = try std.fmt.allocPrint(arena, "worktree-{s}", .{name}); + // --force: discard any uncommitted scratch work — the whole point of + // `remove` is to throw away an abandoned tab (#112). + const rm = runCapped(gpa, io, &.{ "git", "worktree", "remove", "--force", wt_path }, 8192, 8192, 30_000) catch { + try out.print("✗ could not remove {s} (not a git repository, or no such worktree)\n", .{wt_path}); + return; + }; + defer { + gpa.free(rm.stdout); + gpa.free(rm.stderr); + } + if (!ranOk(rm)) { + try out.print("✗ couldn't remove {s}: {s}", .{ wt_path, rm.stderr }); + return; + } + // -D (force) so an unmerged scratch branch is still deleted. + if (runCapped(gpa, io, &.{ "git", "branch", "-D", wt_branch }, 8192, 8192, 30_000)) |r| { + gpa.free(r.stdout); + gpa.free(r.stderr); + } else |_| {} + try out.print("✓ removed {s} and branch {s}\n", .{ wt_path, wt_branch }); + return; + } + + if (std.mem.eql(u8, action, "prune")) { + // Drops git's registrations for worktrees whose dirs were deleted out of + // band, and with `older-than ` the stale DIRECTORIES too (#112). + return worktree_prune.pruneCommand(gpa, io, arena, out, args[1..]); + } + + try out.print("unknown worktree command '{s}' — use: graff worktree list | merge | remove | prune [older-than ]\n", .{action}); +} + +test { // split-out module: unreferenced, its tests silently never run + _ = worktree_prune; +}