diff --git a/.env.example b/.env.example index fe1165d..4cd543d 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,8 @@ PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440 PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60 PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400 +# A queued Gitea job with a compatible idle runner is considered polling-stalled after this grace period. +PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS=120 PROJECTBRAIN_PUBLIC_URL=https://projectbrain.example.com # Optional server-to-server credential used only by the governed ITWorx MCP Hub. PROJECTBRAIN_HUB_SERVICE_TOKEN= diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 752b8ec..a14f107 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `a53f99c891eb6bcaf4bbbaa7125d711afad0f844`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `88fab8df1542019289e947f092c2b387e0825c60`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 219eaa6..de12847 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "a53f99c891eb6bcaf4bbbaa7125d711afad0f844", + "sourceRevision": "88fab8df1542019289e947f092c2b387e0825c60", "files": [ { "path": ".dockerignore", @@ -9,8 +9,8 @@ }, { "path": ".env.example", - "sha256": "d336770cf506118024859fb5b9759bf6b7845e9c9c97ff29e3d243082d771b32", - "bytes": 2342 + "sha256": "2a6572404e71feb1ff45d8afbe0e447c16d064753650a8c1b42e80ef507a2a16", + "bytes": 2492 }, { "path": ".gitattributes", @@ -49,8 +49,8 @@ }, { "path": "backend/.env.example", - "sha256": "d8316a5c50407c5fad71cd659cc18a25ced26b49422ed7ce05b791feb7728cc5", - "bytes": 1632 + "sha256": "d125a56d52a11b74bb5408d37dc49996faf36a9326097ed13ecefa6d2493d4ba", + "bytes": 1676 }, { "path": "backend/.gitignore", @@ -229,8 +229,8 @@ }, { "path": "backend/src/config/env.ts", - "sha256": "c9de76c76754fe0625c192790ced90828efb1cccf2b5e4a2a99074f045eee8d5", - "bytes": 9132 + "sha256": "0bb0c3912cb25fa025a8c8515bb91b0c6a1df9817f7e7e6670a93c701b003cef", + "bytes": 9324 }, { "path": "backend/src/db/prisma.ts", @@ -309,13 +309,13 @@ }, { "path": "backend/src/integrations/gitea/client.ts", - "sha256": "642e4260e52c5ede4eff5596ef66f36d990c991a40a75cd611099d2975fbccb4", - "bytes": 16274 + "sha256": "f5b8e15f010582a06d241e7c316931a84ecff767165a035f090fb14385570642", + "bytes": 16437 }, { "path": "backend/src/integrations/gitea/policy.ts", - "sha256": "a6c2fe82f9ca0d9a4420922cdff014945cc88a101cf38a369712f40dd415ae5e", - "bytes": 4676 + "sha256": "4c9d46a0b75fa235eda9e96ddfff72f975b6015da735c009fd10186d6a7ba6db", + "bytes": 5628 }, { "path": "backend/src/integrations/gitea/portfolio.ts", @@ -339,8 +339,8 @@ }, { "path": "backend/src/mcp/autonomyServer.ts", - "sha256": "a92e7714865862c7f218cc04f5eee23ec8fd6d51fecc9987703c165523d69240", - "bytes": 22572 + "sha256": "c1d0f515e5d1da01807ff3c32a8451b214c963dadffdccb313c7dbde0463c69c", + "bytes": 27115 }, { "path": "backend/src/mcp/featureChangeset.ts", @@ -364,13 +364,13 @@ }, { "path": "backend/src/mcp/server.ts", - "sha256": "cadbf35742b2857c302acce4d770dc31fb7887b9dc43ad40af44fbaee7fcf009", - "bytes": 78413 + "sha256": "988dbb35051a854e71a92f08e34cb4b8ef415467f7857a28bab82f337e151258", + "bytes": 78383 }, { "path": "backend/src/mcp/skills.ts", - "sha256": "62f72fff51f45af2f88e52cfc6ee53758298866bb6a8124c585b1dcc18af219f", - "bytes": 10511 + "sha256": "40a9990e37c8e31abb530dbdc8ea6026a9aafb6e762491a972148f52a0ab580f", + "bytes": 11426 }, { "path": "backend/src/mcp/widget.ts", @@ -409,8 +409,13 @@ }, { "path": "backend/src/repositories/autonomyRepository.ts", - "sha256": "4afd9ae0d74773bb1d9c8371f430699b95b62ea965570127479faea3d70c2bf3", - "bytes": 43286 + "sha256": "e1e16ca9ac8976d08eaf56b46876a970f8bb7a99ea6fcc578ed331825bb0efd0", + "bytes": 42376 + }, + { + "path": "backend/src/repositories/autonomyTypes.ts", + "sha256": "39d6bfb5f968828c0edb9914a27b7cb9789643670a110cbe9408fad2c005c6f5", + "bytes": 3291 }, { "path": "backend/src/repositories/blueprintsRepository.ts", @@ -504,8 +509,8 @@ }, { "path": "backend/src/routes/activity.ts", - "sha256": "60dac17926b995b1e2ffd6ac6775effd76c6a063b88e8c864fddfedbe8913745", - "bytes": 12117 + "sha256": "2f17a7e26c3a33c18f3dca43fbde9e60709b4b962c0de00ab9e386fc5520c6c8", + "bytes": 12844 }, { "path": "backend/src/routes/aiActions.ts", @@ -524,8 +529,8 @@ }, { "path": "backend/src/routes/autonomy.ts", - "sha256": "67003ed371d177b239731cdc6f5f0562f199a2c0b6bfc9dfa13e7fc62cf17449", - "bytes": 12746 + "sha256": "70cb320dcf00c6d83b34838d6693977d4d51a3300425b2f02eb4a885eef7d606", + "bytes": 14023 }, { "path": "backend/src/routes/blueprints.ts", @@ -584,8 +589,8 @@ }, { "path": "backend/src/routes/giteaIntegration.ts", - "sha256": "b03bab5dcd951f6160c7251809f97db936729cec0b9070607a8b0f4d873ad7ca", - "bytes": 45151 + "sha256": "72ab237e51764caca06633579045af8b9a5d3c79f15984795d81a3a4220315c2", + "bytes": 45137 }, { "path": "backend/src/routes/health.ts", @@ -629,8 +634,8 @@ }, { "path": "backend/src/routes/operatingSystem.ts", - "sha256": "91b4a4e8d253c272f05fd0f152efe4fc45e03418bebd511c71663609395b0a0b", - "bytes": 20391 + "sha256": "462515ee00f4eaf32230cbff9fd76bcfe04ad11bf7f21e2e3b2b6153e693576e", + "bytes": 20429 }, { "path": "backend/src/routes/p7Runtime.ts", @@ -719,8 +724,8 @@ }, { "path": "backend/src/services/activityRuntime.ts", - "sha256": "5ba4b0cb64392564dadc2b46a3d4724883bd6fe96cd9005c1e17b5763463ec34", - "bytes": 10117 + "sha256": "31160a8c0486f46266b330a76d7eef44e149f8d7fc4a186fac41a7d01a998099", + "bytes": 15868 }, { "path": "backend/src/services/analysisStrategy.ts", @@ -742,6 +747,11 @@ "sha256": "694272e98f7da3d4d086be42c4602c4a7104f2e21d0cb38eacbc2c52d3e82ba7", "bytes": 1967 }, + { + "path": "backend/src/services/autonomyQueueOrdering.ts", + "sha256": "d53b02956c8c82e85ea4222d8439ea68f5480d6256edbabebfb2cffd33ea324e", + "bytes": 2112 + }, { "path": "backend/src/services/autonomyRecovery.ts", "sha256": "d571be091f4a684c0f2bb41e0629faded94ba559cc9ff2265f1bd59f28261d17", @@ -759,8 +769,8 @@ }, { "path": "backend/src/services/autonomyRuntime.ts", - "sha256": "9fe64c370569e5bcdd82de8a033278cb337b79d51ed7c199a62fd1a3accb1b36", - "bytes": 17147 + "sha256": "53cf372c13ad15794b9a9a3659dbc8148a6efc281906ce56e4c18a1104e86439", + "bytes": 17188 }, { "path": "backend/src/services/autonomyRuntimeControl.ts", @@ -769,8 +779,8 @@ }, { "path": "backend/src/services/autonomyScheduler.ts", - "sha256": "d347b998a1ea84a1207d9a45b3c654648bb3b7061ebfaf747694a61a5f83f8a3", - "bytes": 5338 + "sha256": "ee426bab718104bdc0a62731efb21a4d850615eaf77195eafd78b342615a15ac", + "bytes": 5699 }, { "path": "backend/src/services/autonomyWork.ts", @@ -877,10 +887,20 @@ "sha256": "f7850ab33bf1735397671271d99d0d2d6dd67cb71ab3434378bd61e43834a352", "bytes": 14958 }, + { + "path": "backend/src/services/runnerClaimHealth.ts", + "sha256": "db6099eb49e63a3e1cfda252d188a9d64234af447633f746219a3444177f6ef2", + "bytes": 5669 + }, + { + "path": "backend/src/services/runnerCompatibility.ts", + "sha256": "4ba351995bce12cbfaec9fce008ac763c1348ac5676f7ad1b96ecab121d9dd4b", + "bytes": 911 + }, { "path": "backend/src/services/workGraph.ts", - "sha256": "081e18915f94e404ae688a992d986d19b271a5815fd8ee22deabb66b9290cbe8", - "bytes": 41038 + "sha256": "2b20f212f6436581156cc8f27e1ed2d0ba794cd644fde628477583c271a05b02", + "bytes": 44039 }, { "path": "backend/src/types/api.ts", @@ -904,8 +924,8 @@ }, { "path": "backend/test/activity-runtime.test.ts", - "sha256": "514b2c19069dc219c87af15d227ccfbbbc024226c5772e44d9d39181eae5fa05", - "bytes": 7838 + "sha256": "20b4fe974f87b7e9afdc415102d5605a4a6110fd3dd79e3a89063228d606e6cc", + "bytes": 29613 }, { "path": "backend/test/analysis-job-capacity.test.ts", @@ -924,8 +944,8 @@ }, { "path": "backend/test/autonomy.test.ts", - "sha256": "c37274b9912470b84145acd9d58ce3485da5784c7cd79a6d8c9a65747eba6501", - "bytes": 19280 + "sha256": "147768492fc93460bfcf9c7d7de403c641002aeb022d9a1edf91362ecc36c9bd", + "bytes": 20760 }, { "path": "backend/test/autonomyRecovery.test.ts", @@ -939,8 +959,8 @@ }, { "path": "backend/test/autonomyScheduler.test.ts", - "sha256": "4edfcba3f357ad49870995c591b008b421d5aab5a6689797302253b930c7d2d6", - "bytes": 2285 + "sha256": "537019439acf77a42432f1a6157a6c98bdae4df3e38b2430c9989ec2300c95fa", + "bytes": 3178 }, { "path": "backend/test/autonomyWork.test.ts", @@ -959,8 +979,8 @@ }, { "path": "backend/test/database-autonomy.test.ts", - "sha256": "6d12b8b4cc278cef1a186ee0e4e4cb4685ce82c33af9cae9349d54868e7fec55", - "bytes": 14589 + "sha256": "026e683c392e3b17bfea586327944a030a364b4f3c21312b0b8655aec30fdfcb", + "bytes": 47937 }, { "path": "backend/test/database-repository-sync.test.ts", @@ -1004,8 +1024,8 @@ }, { "path": "backend/test/gitea-integration.test.ts", - "sha256": "0102bce2bd523d082b26ae20934b85b396efb81b1a41961f0ccf0cb53f2d7ef2", - "bytes": 35212 + "sha256": "fedc1f48b814c0bbd79c80823f3b76f3210c8935a602e845b133dfc026dc7097", + "bytes": 36414 }, { "path": "backend/test/gitea-portfolio-mcp.test.ts", @@ -1024,8 +1044,13 @@ }, { "path": "backend/test/native-ci-discovery.test.ts", - "sha256": "3b193c88110d7d9b2328920edd0211c16803de901cb81f28d921db78d2fbb42f", - "bytes": 4873 + "sha256": "7b86c426bb31b47f0ea7207896e18bdaaee396a830437c13ed33564945f07746", + "bytes": 4926 + }, + { + "path": "backend/test/operatingSystemRoutes.test.ts", + "sha256": "49f8396c724aa07dc56d10339e6f3b1688817c52aa8a69a31d8b6b87c7341385", + "bytes": 2164 }, { "path": "backend/test/operatingSystemServices.test.ts", @@ -1064,8 +1089,8 @@ }, { "path": "backend/test/remediation-regressions.test.ts", - "sha256": "9189c57b5f7dac2a534b9da1c3d64f9ea172e59a828d400874c1acc64e6305ba", - "bytes": 5236 + "sha256": "c13fcafb7ad72699e7d4aa2a64b555ea4bce6c88fb2ccd425d5154751af14d80", + "bytes": 5380 }, { "path": "backend/test/repository-analysis.test.ts", @@ -1092,6 +1117,11 @@ "sha256": "55d5478b8896121fc90e5766977b3432fbf0d44d8bbbca120b21efadaf1df7af", "bytes": 4495 }, + { + "path": "backend/test/runnerClaimHealth.test.ts", + "sha256": "d4e000dba5a661b86081070deb5735d63cfafbdde84e7aeb97c16c6b61ecda7c", + "bytes": 4129 + }, { "path": "backend/test/system-readiness.test.ts", "sha256": "15bfd2d8434d3ec9b1e4cf34256a2fecd3304ebe1865583751612e89d449557b", @@ -1134,8 +1164,8 @@ }, { "path": "docker-compose.yml", - "sha256": "40498a29e0553c1bef0095c3994df6e5d7b203e5c1eff81d76b506c32a25e521", - "bytes": 6539 + "sha256": "49cc781171f6f73cd353e86a23ffe6f0e9557814fde6a4f03db17fe3bbf9c744", + "bytes": 6634 }, { "path": "docker/nginx.conf", @@ -1194,8 +1224,8 @@ }, { "path": "e2e/attention-auto-mode.spec.ts", - "sha256": "aefee916bd9e33fa0c80ecb2ab80fa2a02494d6f516c1e8d20697cec40199436", - "bytes": 4867 + "sha256": "7b20f45cefcdb26f6253d51a43dbaebe20fba13651eba2b38baa3e82015c5d9f", + "bytes": 9174 }, { "path": "e2e/core-journeys.spec.ts", @@ -1254,13 +1284,13 @@ }, { "path": "pnpm-lock.yaml", - "sha256": "1072b0667e9013f4bf07d340639f76117cc7a9cb52bddb0b5ee338bc71256278", - "bytes": 237449 + "sha256": "afd7c9990dc30f8071e31429466cb526a6cbc577da752c8f2f1f96466a7bbe44", + "bytes": 237487 }, { "path": "pnpm-workspace.yaml", - "sha256": "63f93204f0a81b94d599ebd579e093f375f5a92bcd78f87ca66d60179fbe7279", - "bytes": 682 + "sha256": "a7a0a18f59047cc34b94900ff0284ad6d32830cd8697abc78873b0038c811ab7", + "bytes": 722 }, { "path": "postcss.config.js", @@ -1987,10 +2017,15 @@ "sha256": "4766c0de7eb9049eb29e7d4678f986511eb3601a093847c03a915b2a623e6108", "bytes": 5277 }, + { + "path": "src/features/autonomy/components/SortableWorkQueue.tsx", + "sha256": "a12ca769974ace3463bf6f51152a4ba067a7611b9d42580c7571aceaf5f8c398", + "bytes": 5632 + }, { "path": "src/features/autonomy/pages/AutoMode.tsx", - "sha256": "b4b23d9ad9ce1b53d08a1bd3eea581ee318d942d90b5ee9012817211df040e78", - "bytes": 23016 + "sha256": "76e0b1f365a876729faddf5f5ad804e2557d9a62fce9caba0f337d13962c0651", + "bytes": 26528 }, { "path": "src/features/blueprint/pages/BlueprintEngine.tsx", @@ -2289,8 +2324,8 @@ }, { "path": "src/features/portfolio/components/PortfolioExecutionPulse.tsx", - "sha256": "aadbc725d8d3f23781188c2a39aee8017cb4ea6b22b7f47d3df6aeed5f09fdaf", - "bytes": 11228 + "sha256": "0b29e5fbd2267c6e8b7ea1b27e9ddebe77a72a62535c82a5f4e8e7f00b8943c9", + "bytes": 12059 }, { "path": "src/features/portfolio/components/PortfolioPulseSummary.tsx", @@ -2299,8 +2334,8 @@ }, { "path": "src/features/portfolio/components/PortfolioRuntimeDashboard.tsx", - "sha256": "2fc6b66ec4c991603344b0368193d2252d37836edba541f27b58d7c9a70d2634", - "bytes": 16223 + "sha256": "2d32fbe0aac8ab4d491b27f4e198194e0ea5beaeb774d5aba5714c9513ee07d3", + "bytes": 20490 }, { "path": "src/features/portfolio/components/PortfolioSavedViews.tsx", @@ -2314,8 +2349,8 @@ }, { "path": "src/features/portfolio/components/RuntimeJobLane.tsx", - "sha256": "0b1bc27f733f6f0b63fb5b6413a1f116079d71b91ef1e28185bb574cef6a5230", - "bytes": 4216 + "sha256": "8538874c869bcb44ad11e082ae2cabfd575cbdb16fe157259bac8ec5fe2f6e9c", + "bytes": 4613 }, { "path": "src/features/portfolio/pages/PortfolioCommandCenter.tsx", @@ -2534,8 +2569,8 @@ }, { "path": "src/shared/api/autonomyApi.ts", - "sha256": "93afe8f0a03869c16a49e7cc637af61efce1045c805a4a28913933a82fd6529a", - "bytes": 3971 + "sha256": "fb86b5a89d3d49d37e075a1ebb1ece61ffe3d67fcae707e46b042e664451634c", + "bytes": 4349 }, { "path": "src/shared/api/b1ReadBackend.ts", @@ -2589,8 +2624,8 @@ }, { "path": "src/shared/api/portfolioTypes.ts", - "sha256": "76f630cb4d2ca22388cd095937323c160c6cf2196cbbd2af02ba6c8fd7d53184", - "bytes": 7952 + "sha256": "a2b105d685934b23fef8b495c4f1c09ecd3230dd10f6593a68ebedc499a27390", + "bytes": 8969 }, { "path": "src/shared/api/projectBrainClient.ts", @@ -2729,13 +2764,13 @@ }, { "path": "src/shared/lib/activityRuntimePresentation.test.ts", - "sha256": "6292ed092e473e9843f751b3e7a9dc6994da813a2afa6bd89974b89776a00037", - "bytes": 4469 + "sha256": "f67ba1030988cf1afa4dd16320445ecf26837db1c2c89da40d073359d7f77ff7", + "bytes": 8358 }, { "path": "src/shared/lib/activityRuntimePresentation.ts", - "sha256": "f98421bb8783955d01b5224858595be4ab9fd2e181a376b112adc41f983f2c33", - "bytes": 4325 + "sha256": "c0178bfabd514bf9230c84c8e7308342bd5b4ad78b01da4d352093db23e50b6c", + "bytes": 7868 }, { "path": "src/shared/lib/appLogger.ts", diff --git a/backend/.env.example b/backend/.env.example index fc3b039..221a985 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -37,4 +37,5 @@ PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440 PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60 PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400 +PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS=120 PROJECTBRAIN_PUBLIC_URL= diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 78a7bc1..5502bd5 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -43,6 +43,7 @@ const envSchema = z.object({ // the ProjectBrain MCP plug-in. Keep a 30-minute grace window so ordinary // scheduler jitter does not make a healthy executor appear disconnected. PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: z.coerce.number().int().min(60).max(86_400).default(5_400), + PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS: z.coerce.number().int().min(30).max(3_600).default(120), }); export type AppEnv = z.infer; @@ -108,6 +109,7 @@ export const loadEnv = ( PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: input.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES, PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: input.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS, PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: input.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS, + PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS: input.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS, }); if (parsed.NODE_ENV === "production" && !parsed.DATABASE_URL) { diff --git a/backend/src/integrations/gitea/client.ts b/backend/src/integrations/gitea/client.ts index 02ac2f4..adbf05b 100644 --- a/backend/src/integrations/gitea/client.ts +++ b/backend/src/integrations/gitea/client.ts @@ -163,9 +163,10 @@ export class GiteaClient { private async getMergeStrategyKey(): Promise<"Do" | "do"> { if (this.mergeStrategyKey) return this.mergeStrategyKey; const { version } = await this.request<{ version?: string }>("/version"); - const match = /^1\.(25|26|27)(?:\.\d+)?(?:[-+].*)?$/.exec(version ?? ""); - if (!match) throw new GiteaApiError(500, "GITEA_VERSION_UNSUPPORTED", "Gitea merge compatibility requires an identified 1.25, 1.26, or 1.27 server"); - this.mergeStrategyKey = match[1] === "25" ? "Do" : "do"; + const legacyMatch = /^1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?$/.exec(version ?? ""); + const modernMatch = /^(28)(?:\.\d+){1,2}(?:[-+].*)?$/.exec(version ?? ""); + if (!legacyMatch && !modernMatch) throw new GiteaApiError(500, "GITEA_VERSION_UNSUPPORTED", "Gitea merge compatibility requires an identified 1.14, 1.25, 1.26, 1.27, or 28.x server"); + this.mergeStrategyKey = legacyMatch && ["14", "25"].includes(legacyMatch[1] ?? "") ? "Do" : "do"; return this.mergeStrategyKey; } diff --git a/backend/src/integrations/gitea/policy.ts b/backend/src/integrations/gitea/policy.ts index 26b7436..d711e83 100644 --- a/backend/src/integrations/gitea/policy.ts +++ b/backend/src/integrations/gitea/policy.ts @@ -69,9 +69,29 @@ export const exactHeadActionRuns = (runs: GiteaActionRun[], branch: string, head (!pr.head?.ref || pr.head.ref === branch) && (!pr.head?.sha || pr.head.sha === headSha)); }); +const isManagedFullValidationRun = (run: GiteaActionRun): boolean => { + const workflowPath = run.path.split("@")[0]?.trim().toLowerCase() ?? ""; + const title = run.display_title.trim().toLowerCase(); + return (run.event === "workflow_dispatch" || run.event === "pull_request") + && workflowPath.endsWith("/managed-validation.yml") + && title === "managed validation / full"; +}; + +export const successfulFullActionRun = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun => { + const fullRuns = exactHeadActionRuns(runs, branch, headSha) + .filter(isManagedFullValidationRun) + .sort((left, right) => right.run_number - left.run_number || right.id - left.id); + const latest = fullRuns[0]; + if (!latest || latest.status !== "completed" || latest.conclusion !== "success") { + throw new Error("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); + } + return latest; +}; + export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => { const exactRuns = exactHeadActionRuns(runs, branch, headSha); if (exactRuns.length === 0) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + successfulFullActionRun(exactRuns, branch, headSha); // Gitea retains failed attempts when the same workflow is rerun for an // unchanged revision. Authorization therefore follows the newest run of each diff --git a/backend/src/mcp/autonomyServer.ts b/backend/src/mcp/autonomyServer.ts index 4943e62..9850ec5 100644 --- a/backend/src/mcp/autonomyServer.ts +++ b/backend/src/mcp/autonomyServer.ts @@ -1,8 +1,10 @@ import { z } from "zod"; import type { AppEnv } from "../config/env.js"; import { prisma } from "../db/prisma.js"; -import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, normalizeAutonomyState, reportAutonomyWork, resumeAutonomy, retryAutonomyBlockerIfDue } from "../repositories/autonomyRepository.js"; +import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, normalizeAutonomyState, reorderAutonomyQueue, reportAutonomyWork, resetAutonomyQueueOrder, resumeAutonomy, retryAutonomyBlockerIfDue } from "../repositories/autonomyRepository.js"; import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js"; +import { createGiteaActivityRuntimeLoader } from "../services/activityRuntime.js"; +import { evaluateRunnerClaimHealth } from "../services/runnerClaimHealth.js"; import { compactAutonomyState, recordAutonomyExecutorHeartbeat, touchAutonomyExecutorHeartbeat } from "../services/autonomyRuntime.js"; import { syncGitSources } from "../services/gitSourceSync.js"; import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js"; @@ -60,6 +62,7 @@ const reportWithConflictRetry = async ( export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { const server = createBaseProjectBrainMcpServer(env, actor); + const loadGiteaRuntime = createGiteaActivityRuntimeLoader(env); server.registerTool("list_auto_mode_projects", { title: "List ProjectBrain Auto Mode projects", @@ -71,11 +74,15 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async ({ enabled_only, statuses, max_projects }) => { - const projects = await prisma.project.findMany({ + const [projects, runnerRuntime] = await Promise.all([ + prisma.project.findMany({ where: { deletedAt: null }, orderBy: { updatedAt: "desc" }, select: { slug: true, readableId: true, name: true, workspaceState: { select: { notesJson: true } } }, - }); + }), + loadGiteaRuntime(), + ]); + const runnerClaimHealth = evaluateRunnerClaimHealth(runnerRuntime, { stallSeconds: env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS }); const selectedStatuses = statuses?.length ? new Set(statuses) : undefined; const rows = projects.flatMap((project) => { const notes = asRecord(project.workspaceState?.notesJson); @@ -117,7 +124,33 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { retry_at: state.blocker?.retryAt, }]; }).sort(compareAutoModeProjectRows).slice(0, max_projects); - return result({ projects: rows }, `${rows.length} Auto Mode project(s) matched. Claimable projects are ordered fairly by oldest last run (never-run projects first), so select the first row and keep processing claim_auto_work → execute → report_auto_work until that project's item or time budget is reached, work becomes busy or terminal, or user action is required.`); + const recoveryText = [ + runnerClaimHealth.status === "stalled" + ? "Runner claim-health is stalled; recovery ownership remains with the governed AppOps runner-fleet controller. Do not restart a still-running runner directly from ProjectBrain." + : "", + !runnerClaimHealth.runnerInventoryComplete + ? "Runner inventory is unavailable; do not infer label compatibility, capacity, or polling stalls from missing runner data." + : "", + !runnerClaimHealth.coverage.complete + ? `Runner claim-health scan is incomplete (${runnerClaimHealth.coverage.observed} queued job(s) observed of ${runnerClaimHealth.coverage.total ?? "unknown"}); do not infer the unobserved queue is healthy.` + : "", + ].filter(Boolean).map((text) => ` ${text}`).join(""); + return result({ projects: rows, runner_claim_health: runnerClaimHealth }, `${rows.length} Auto Mode project(s) matched. Claimable projects are ordered fairly by oldest last run (never-run projects first), so select the first row and keep processing claim_auto_work → execute → report_auto_work until that project's item or time budget is reached, work becomes busy or terminal, or user action is required.${recoveryText}`); + }); + + server.registerTool("get_runner_claim_health", { + title: "Get ProjectBrain runner claim health", + description: "Diagnose queued Gitea jobs against live runner labels and capacity. This surface is read-only: ProjectBrain distinguishes claim grace, capacity wait, label mismatch, polling stalls and incomplete runner inventory, while the governed AppOps runner-fleet controller remains the only recovery authority.", + inputSchema: {}, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async () => { + const runtime = await loadGiteaRuntime(); + const claimHealth = evaluateRunnerClaimHealth(runtime, { stallSeconds: env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS }); + const coverage = claimHealth.coverage.complete + ? "queued-job scan complete" + : `queued-job scan incomplete (${claimHealth.coverage.observed} observed of ${claimHealth.coverage.total ?? "unknown"})`; + const inventory = claimHealth.runnerInventoryComplete ? "runner inventory complete" : "runner inventory unavailable; job diagnoses are unknown"; + return result({ runner_claim_health: claimHealth }, `Runner claim health is ${claimHealth.status}; ${coverage}; ${inventory}; oldest runnable observed wait=${claimHealth.oldestWaitSeconds}s; oldest continuously claimable wait=${claimHealth.oldestClaimableWaitSeconds}s; recovery owner=${claimHealth.recoveryOwner}.`); }); server.registerTool("heartbeat_auto_executor", { @@ -237,6 +270,29 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { return result({ state: state as unknown as Record }, `Roadmap '${roadmap.title}' imported with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`); }); + server.registerTool("reorder_auto_queue", { + title: "Reorder ProjectBrain Auto Mode queue", + description: "Persist an explicit owner-defined order for all currently pending executable Auto Mode work items in one project. Supply the complete current item-id list in desired order. Running/completed/future work is not reorderable and dependency eligibility still wins at claim time.", + inputSchema: { + project_id: z.string().min(1), + item_ids: z.array(z.string().min(1)).max(500), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, item_ids }) => { + const state = await reorderAutonomyQueue(project_id, item_ids, actor); + return result({ state: compactAutonomyState(state, { itemLimit: 25, eventLimit: 5 }) as unknown as Record }, `Auto Mode queue reordered; ${item_ids.length} pending executable item(s) now have explicit owner order.`); + }); + + server.registerTool("reset_auto_queue_order", { + title: "Restore automatic ProjectBrain Auto Mode scheduling", + description: "Clear explicit manual ordering for one project's Auto Mode queue so the normal ProjectBrain scheduler again ranks ready work by scope, risk, dependencies, downstream impact and configured scheduling policy.", + inputSchema: { project_id: z.string().min(1) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id }) => { + const state = await resetAutonomyQueueOrder(project_id, actor); + return result({ state: compactAutonomyState(state, { itemLimit: 25, eventLimit: 5 }) as unknown as Record }, "Automatic Auto Mode scheduling order restored."); + }); + server.registerTool("resume_auto_mode", { title: "Resume blocked ProjectBrain Auto Mode", description: "Clear the current Auto Mode blocker and make its blocked roadmap phase/item claimable again. The roadmap, completed phases and completed work are preserved; only blocker/retry state for the blocked work is reset.", diff --git a/backend/src/mcp/server.ts b/backend/src/mcp/server.ts index ba30a0c..b21d14f 100644 --- a/backend/src/mcp/server.ts +++ b/backend/src/mcp/server.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { AppEnv } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { GiteaApiError } from "../integrations/gitea/client.js"; -import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js"; +import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest, successfulFullActionRun } from "../integrations/gitea/policy.js"; import { finishExternalOperation, getGiteaClient, listLinkedRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, type LinkedRepository } from "../integrations/gitea/service.js"; import type { GiteaContent } from "../integrations/gitea/types.js"; import { assertFeatureDeliveryActionEvidence, canSyncFeatureDeliveryValidation, getFeatureDelivery, listFeatureDeliveries, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js"; @@ -717,8 +717,8 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch); const head = await client.getBranch(link!.owner, link!.repository, branch); const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 }); - const verifiedRuns = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id); - const run = verifiedRuns.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest); + assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id); + const run = successfulFullActionRun(response.workflow_runs, branch, head.commit.id); const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() }; return asResult({ run, delivery: await recordFeatureDeliveryActionValidation(project_id, delivery_id, evidence, actor) }, "Gitea Actions validation verified and persisted"); }, true)); diff --git a/backend/src/mcp/skills.ts b/backend/src/mcp/skills.ts index 03692e6..509c81b 100644 --- a/backend/src/mcp/skills.ts +++ b/backend/src/mcp/skills.ts @@ -9,7 +9,7 @@ const definitions: SkillDefinition[] = [ { name: "projectbrain-operator", description: "Operate Auto Mode with ProjectBrain as controller and Gitea as the canonical repository execution and evidence surface.", - body: `# ProjectBrain operator\n\n1. Start with projectbrain.project.list and projectbrain.context.get when the ProjectBrain connector is available. Durable ProjectBrain state and repository planning files, not chat history, are the source of truth.\n2. ProjectBrain is the controller: it owns Auto policy, roadmap state, dependencies, recovery and human-attention decisions. Gitea is the only primary repository execution surface. ChatGPT may orchestrate and reason, but code changes must travel through governed Gitea branches, validation, pull requests and merge evidence. No OpenAI API key is required for this operating model.\n3. For implementation work, use the canonical branch returned as work.execution.branch. When working directly from repository planning truth without a ProjectBrain claim call, derive the same branch as projectbrain/auto/: lowercase the durable task id and replace non alphanumeric, dot, underscore or hyphen runs with hyphens. The existence of that branch is the atomic execution claim. If it already exists, resume it; never create a competing branch for the same task.\n4. Load repository context from the current default branch, then resume or create the canonical non-default work branch. Use the Gitea workspace changeset flow for edits, run the narrowest managed validation profile and a full profile before merge when required, open or reuse the PR, confirm exact-head validation evidence, review the diff, and merge the exact validated head. Direct default-branch writes remain forbidden.\n5. An open PR is unfinished. A work item is implementation-complete only when its canonical PR is merged and the exact PR head has successful required Gitea Actions evidence. ProjectBrain then reconciles that evidence and downstream state back into Auto Mode.\n6. heartbeat_auto_executor, claim_auto_work and report_auto_work remain backward-compatible controller coordination tools. They are not the repository correctness authority. A missing or interrupted report_auto_work callback must never invalidate a valid Gitea branch/PR/validation/merge chain. When a compatibility lease is present, reuse it; after Gitea reconciliation a late report is an idempotent replay.\n7. For brainstorm or phase-review work, inspect ProjectBrain context plus repository roadmap/backlog, findings and evidence. Persist actionable implementation work into repository planning truth or ProjectBrain durable tasks, then execute resulting code work through the same canonical Gitea path. Do not invent routine approvals.\n8. Treat transient runner, Gitea, network and provider failures as retryable. Escalate to human attention only for credentials, policy authorization, destructive risk or a genuine product decision. Local models such as Ollama are optional helpers when explicitly permitted; they do not change the Gitea execution contract.\n`, + body: `# ProjectBrain operator\n\n1. Start with projectbrain.project.list and projectbrain.context.get when the ProjectBrain connector is available. Durable ProjectBrain state and repository planning files, not chat history, are the source of truth.\n2. ProjectBrain is the controller: it owns Auto policy, roadmap state, dependencies, recovery and human-attention decisions. Gitea is the only primary repository execution surface. ChatGPT may orchestrate and reason, but code changes must travel through governed Gitea branches, validation, pull requests and merge evidence. No OpenAI API key is required for this operating model.\n3. For implementation work, use the canonical branch returned as work.execution.branch. When working directly from repository planning truth without a ProjectBrain claim call, derive the same branch as projectbrain/auto/: lowercase the durable task id and replace non alphanumeric, dot, underscore or hyphen runs with hyphens. The existence of that branch is the atomic execution claim. If it already exists, resume it; never create a competing branch for the same task.\n4. Load repository context from the current default branch, then resume or create the canonical non-default work branch. During implementation, use the coding workspace for the narrowest relevant tests after each behavioral slice and broader local regression at coherent checkpoints. Do not dispatch managed Gitea validation for every slice, subcommit, or small edit, and do not push solely to obtain runner evidence. Use a remote narrow profile only when the needed environment is unavailable locally. Dispatch one managed full profile when the exact branch HEAD is genuinely merge-ready; if that HEAD changes, treat the prior full result as stale and wait until the new HEAD is merge-ready before rerunning it. Open or reuse the PR, confirm exact-head full evidence, review the diff, and merge that exact validated head. Direct default-branch writes remain forbidden.\n5. An open PR is unfinished. A work item is implementation-complete only when its canonical PR is merged and the exact PR head has successful exact-head validation evidence from required Gitea Actions. ProjectBrain then reconciles that evidence and downstream state back into Auto Mode.\n6. heartbeat_auto_executor, claim_auto_work and report_auto_work remain backward-compatible controller coordination tools. They are not the repository correctness authority. A missing or interrupted report_auto_work callback must never invalidate a valid Gitea branch/PR/validation/merge chain. When a compatibility lease is present, reuse it; after Gitea reconciliation a late report is an idempotent replay.\n7. For brainstorm or phase-review work, inspect ProjectBrain context plus repository roadmap/backlog, findings and evidence. Persist actionable implementation work into repository planning truth or ProjectBrain durable tasks, then execute resulting code work through the same canonical Gitea path. Do not invent routine approvals.\n8. Treat transient runner, Gitea, network and provider failures as retryable. Escalate to human attention only for credentials, policy authorization, destructive risk or a genuine product decision. Local models such as Ollama are optional helpers when explicitly permitted; they do not change the Gitea execution contract.\n`, }, { name: "repository-bootstrap", @@ -19,7 +19,7 @@ const definitions: SkillDefinition[] = [ { name: "feature-delivery", description: "Plan, implement, validate, review, and merge repository work through ProjectBrain's governed feature-delivery loop.", - body: `# Feature delivery\n\n1. Load projectbrain.delivery.get when resuming; durable state, not chat history, is the source of truth.\n2. Create a complete plan. ProjectBrain auto-authorizes owner-controlled MCP delivery plans, so continue directly to start or resume the valid isolated non-default delivery branch instead of asking for routine plan approval.\n3. Apply up to 25 create/update/delete operations atomically with projectbrain.delivery.changeset.apply. Refresh the branch head and file SHAs before every changeset and retry only after re-reading a conflict.\n4. Resume through gitea.workspace.status. Dispatch the narrowest fixed validation profile with gitea.workspace.run and use full before merge when supported. Respect pollAfterMs and poll only while terminal=false; after a transient runner failure, dispatch the same managed profile again instead of using the Gitea UI. Never stream logs into the browser unless diagnosing one selected failed job.\n5. Open or update the pull request, synchronize exact-head validation evidence, and inspect gitea.change.review.\n6. When completion/merge is already part of the user's instruction or an active Auto Mode roadmap, call projectbrain.delivery.merge with the exact head SHA after validation. It is idempotent across retries, rechecks Actions evidence, reconciles ProjectBrain and can clean the merged branch.\n7. Import a compact final execution summary as durable feedback and report changed paths, validation evidence, PR URL and merge state.\n`, + body: `# Feature delivery\n\n1. Load projectbrain.delivery.get when resuming; durable state, not chat history, is the source of truth.\n2. Create a complete plan. ProjectBrain auto-authorizes owner-controlled MCP delivery plans, so continue directly to start or resume the valid isolated non-default delivery branch instead of asking for routine plan approval.\n3. Apply up to 25 create/update/delete operations atomically with projectbrain.delivery.changeset.apply. Refresh the branch head and file SHAs before every changeset and retry only after re-reading a conflict.\n4. Resume through gitea.workspace.status. Prefer local workspace tests while code is changing: targeted checks per behavioral slice and a broader local regression at coherent checkpoints. Do not call gitea.workspace.run after every slice or subcommit and do not push only to obtain runner evidence. Use a narrow managed profile only for a genuine remote-environment gap or an explicit independent checkpoint. When the exact HEAD is merge-ready, dispatch full once; a later code change makes that evidence stale and requires a new full only when the new HEAD is again merge-ready. Respect pollAfterMs and poll only while terminal=false. Never stream logs into the browser unless diagnosing one selected failed job.\n5. Open or update the pull request, synchronize exact-head validation evidence, and inspect gitea.change.review.\n6. When completion/merge is already part of the user's instruction or an active Auto Mode roadmap, call projectbrain.delivery.merge with the exact head SHA after validation. It is idempotent across retries, rechecks Actions evidence, reconciles ProjectBrain and can clean the merged branch.\n7. Import a compact final execution summary as durable feedback and report changed paths, validation evidence, PR URL and merge state.\n`, }, { name: "portfolio-curator", @@ -66,7 +66,7 @@ export const augmentProjectBrainInitialize = (payload: unknown) => { const extensions = capabilities.extensions && typeof capabilities.extensions === "object" ? capabilities.extensions as Record : {}; extensions[EXTENSION_ID] ??= {}; document.result.capabilities = { ...capabilities, extensions }; - const autoInstruction = "ProjectBrain Auto Mode uses Gitea as its only primary repository execution surface. ProjectBrain remains the durable controller for roadmap, dependencies, recovery and human decisions; ChatGPT may orchestrate reasoning, but implementation changes must use governed Gitea branches, exact-head validation, pull requests and merge. For an Auto implementation item, use work.execution.branch when available or the deterministic projectbrain/auto/ branch when working directly from repository planning truth. Branch existence is the atomic claim: resume an existing branch instead of creating a duplicate. Completion is reconciled from the canonical PR, successful exact-head Gitea Actions evidence, its merge commit and downstream ProjectBrain state. heartbeat_auto_executor, claim_auto_work and report_auto_work are backward-compatible coordination/bookkeeping surfaces only; missing report_auto_work must never make a valid Gitea execution appear incomplete. Never write directly to the default branch, bypass validation, or create parallel branches for one durable work id."; + const autoInstruction = "ProjectBrain Auto Mode uses Gitea as its only primary repository execution surface. ProjectBrain remains the durable controller for roadmap, dependencies, recovery and human decisions; ChatGPT may orchestrate reasoning, but implementation changes must use governed Gitea branches, exact-head validation, pull requests and merge. For an Auto implementation item, use work.execution.branch when available or the deterministic projectbrain/auto/ branch when working directly from repository planning truth. Branch existence is the atomic claim: resume an existing branch instead of creating a duplicate. Completion is reconciled from the canonical PR, successful exact-head managed full validation evidence, its merge commit and downstream ProjectBrain state. Inner-loop development is local-first: managed runners are checkpoint and merge gates, not per-slice test workers. heartbeat_auto_executor, claim_auto_work and report_auto_work are backward-compatible coordination/bookkeeping surfaces only; missing report_auto_work must never make a valid Gitea execution appear incomplete. Never write directly to the default branch, bypass validation, or create parallel branches for one durable work id."; document.result.instructions = document.result.instructions ? `${document.result.instructions}\n\n${autoInstruction}` : autoInstruction; return document; }; diff --git a/backend/src/repositories/autonomyRepository.ts b/backend/src/repositories/autonomyRepository.ts index c81b075..2de4da7 100644 --- a/backend/src/repositories/autonomyRepository.ts +++ b/backend/src/repositories/autonomyRepository.ts @@ -8,123 +8,42 @@ import { getFeatureDelivery } from "./featureDeliveryRepository.js"; import { createBrainstormForProject } from "./brainstormRepository.js"; import { parseRoadmapMarkdown } from "../services/autonomyRoadmap.js"; import { decideRecovery } from "../services/recoveryPolicy.js"; +import { applyAutonomyQueueOrder, clearAutonomyQueueOrder, nextManualOrderForAutonomyState } from "../services/autonomyQueueOrdering.js"; import { hasGiteaAutonomyCompletionEvidence, type AutonomyExecutionTarget } from "./autonomyExecutionEvidence.js"; +import type { + AutonomyCompletedLease, + AutonomyEvent, + AutonomyLease, + AutonomyRoadmap, + AutonomyRoadmapItem, + AutonomyRoadmapPhase, + AutonomyRun, + AutonomyState, + AutonomyStatus, + AutonomyWorkKind, + AutonomyWorkOutcome, + RoadmapItemStatus, + RoadmapPhaseStatus, +} from "./autonomyTypes.js"; export { parseRoadmapMarkdown }; export { autonomyRetryDelayMinutes, classifyAutonomyBlocker }; export { hasGiteaAutonomyCompletionEvidence }; export type { AutonomyExecutionTarget }; -export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed"; -export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase"; -export type AutonomyWorkOutcome = "success" | "failed" | "blocked"; -export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked"; -export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked"; - -export type AutonomyRoadmapItem = { - id: string; - title: string; - instruction?: string; - source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug"; - createdAt?: string; - status: RoadmapItemStatus; - attempts: number; - priority?: number; - scope?: "required" | "committed" | "stretch" | "future"; - risk?: "low" | "normal" | "medium" | "high" | "critical"; - estimatedScope?: number; - releaseBlocking?: boolean; - waitingExternal?: boolean; - touchedPaths?: string[]; - domain?: string; - deliveryId?: string; - dependsOn?: string[]; - lastResult?: string; - lastError?: string; - completionEvidence?: string[]; - supersededBy?: string; -}; - -export type AutonomyRoadmapPhase = { - id: string; - title: string; - objective: string; - status: RoadmapPhaseStatus; - brainstormIterations: number; - brainstormConverged: boolean; - validationFailures: number; - items: AutonomyRoadmapItem[]; -}; - -export type AutonomyRoadmap = { - id: string; - title: string; - sourceMarkdown: string; - status: "active" | "completed" | "blocked"; - currentPhaseIndex: number; - phases: AutonomyRoadmapPhase[]; - importedAt: string; - completedAt?: string; -}; - -export type AutonomyLease = { - token: string; - kind: AutonomyWorkKind; - actor: string; - runId?: string; - roadmapId: string; - phaseId: string; - itemId?: string; - createdAt: string; - expiresAt: string; -}; - -export type AutonomyCompletedLease = { - token: string; - outcome: AutonomyWorkOutcome; - summary: string; - addedTasks: string[]; - completedAt: string; -}; - -export type AutonomyRun = { - id: string; - actor: string; - startedAt: string; - reportedWorkItems: number; -}; - -export type AutonomyEvent = { - id: string; - at: string; - type: string; - actor: string; - summary: string; -}; - -export type AutonomyState = { - version: 1; - profile: AutonomyProfile; - enabled: boolean; - status: AutonomyStatus; - autoBrainstorm: boolean; - maxBrainstormIterations: number; - maxConsecutiveFailures: number; - leaseMinutes: number; - maxWorkItemsPerRun: number; - maxRunMinutes: number; - pauseAfterCurrent: boolean; - schedulingPolicy: "balanced" | "downstream-impact"; - roadmap?: AutonomyRoadmap; - activeLease?: AutonomyLease; - blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string }; - recoveryStartedAt?: string; - recoveryAttempts?: number; - maxRecoveryMinutes?: number; - runs: AutonomyRun[]; - completedLeases: AutonomyCompletedLease[]; - events: AutonomyEvent[]; - updatedAt: string; - updatedBy: string; -}; +export type { + AutonomyCompletedLease, + AutonomyEvent, + AutonomyLease, + AutonomyRoadmap, + AutonomyRoadmapItem, + AutonomyRoadmapPhase, + AutonomyRun, + AutonomyState, + AutonomyStatus, + AutonomyWorkKind, + AutonomyWorkOutcome, + RoadmapItemStatus, + RoadmapPhaseStatus, +} from "./autonomyTypes.js"; export type AutonomyWork = { token: string; @@ -220,6 +139,7 @@ const normalizeItem = (value: unknown): AutonomyRoadmapItem | undefined => { ...(asString(row.createdAt) ? { createdAt: asString(row.createdAt) } : {}), ...(asString(row.deliveryId) ? { deliveryId: asString(row.deliveryId) } : {}), ...(typeof row.priority === "number" ? { priority: asInt(row.priority, 50, 0, 100) } : {}), + ...(typeof row.manualOrder === "number" ? { manualOrder: asInt(row.manualOrder, 0, 0, 10_000) } : {}), ...(["required", "committed", "stretch", "future"].includes(asString(row.scope)) ? { scope: asString(row.scope) as AutonomyRoadmapItem["scope"] } : {}), ...(["low", "normal", "medium", "high", "critical"].includes(asString(row.risk)) ? { risk: asString(row.risk) as AutonomyRoadmapItem["risk"] } : {}), ...(typeof row.estimatedScope === "number" ? { estimatedScope: asInt(row.estimatedScope, 0, 0, 10) } : {}), @@ -430,6 +350,7 @@ const assignmentTitle = (input: EnqueueAutonomyTaskInput) => { export const enqueueAutonomyTaskInState = (current: AutonomyState, input: EnqueueAutonomyTaskInput, actor = "owner") => { const next = structuredClone(current) as AutonomyState; const createdAt = nowIso(); + const nextManualOrder = nextManualOrderForAutonomyState(current); const task: AutonomyRoadmapItem = { id: `assignment-${randomUUID()}`, title: assignmentTitle(input), @@ -438,6 +359,7 @@ export const enqueueAutonomyTaskInState = (current: AutonomyState, input: Enqueu createdAt, status: "pending", attempts: 0, + ...(nextManualOrder !== undefined ? { manualOrder: nextManualOrder } : {}), }; const createAssignmentPhase = (): AutonomyRoadmapPhase => ({ id: `projectbrain-opdrachten-${randomUUID()}`, @@ -480,6 +402,28 @@ export const enqueueAutonomyTaskInState = (current: AutonomyState, input: Enqueu return { state, task }; }; +export const reorderAutonomyQueueInState = (current: AutonomyState, orderedItemIds: string[], actor = "owner") => { + const next = applyAutonomyQueueOrder(current, orderedItemIds); + return appendEvent(next, event("autonomy.queue.reordered", actor, `Reordered ${orderedItemIds.length} pending Auto Mode work item(s).`)); +}; + +export const reorderAutonomyQueue = async (projectIdOrSlug: string, orderedItemIds: string[], actor = "owner") => { + const project = await getProjectRow(projectIdOrSlug); + const current = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor))); + return saveState(project, reorderAutonomyQueueInState(current, orderedItemIds, actor), actor); +}; + +export const resetAutonomyQueueOrderInState = (current: AutonomyState, actor = "owner") => { + const next = clearAutonomyQueueOrder(current); + return appendEvent(next, event("autonomy.queue.order_reset", actor, "Restored automatic Auto Mode scheduling order.")); +}; + +export const resetAutonomyQueueOrder = async (projectIdOrSlug: string, actor = "owner") => { + const project = await getProjectRow(projectIdOrSlug); + const current = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor))); + return saveState(project, resetAutonomyQueueOrderInState(current, actor), actor); +}; + const expireLeaseIfNeeded = (state: AutonomyState) => { if (!state.activeLease || new Date(state.activeLease.expiresAt).getTime() > Date.now()) return state; const next = structuredClone(state) as AutonomyState; diff --git a/backend/src/repositories/autonomyTypes.ts b/backend/src/repositories/autonomyTypes.ts new file mode 100644 index 0000000..bd09413 --- /dev/null +++ b/backend/src/repositories/autonomyTypes.ts @@ -0,0 +1,115 @@ +import type { AutonomyProfile } from "@projectbrain/contracts"; + +export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed"; +export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase"; +export type AutonomyWorkOutcome = "success" | "failed" | "blocked"; +export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked"; +export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked"; + +export type AutonomyRoadmapItem = { + id: string; + title: string; + instruction?: string; + source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug"; + createdAt?: string; + status: RoadmapItemStatus; + attempts: number; + priority?: number; + manualOrder?: number; + scope?: "required" | "committed" | "stretch" | "future"; + risk?: "low" | "normal" | "medium" | "high" | "critical"; + estimatedScope?: number; + releaseBlocking?: boolean; + waitingExternal?: boolean; + touchedPaths?: string[]; + domain?: string; + deliveryId?: string; + dependsOn?: string[]; + lastResult?: string; + lastError?: string; + completionEvidence?: string[]; + supersededBy?: string; +}; + +export type AutonomyRoadmapPhase = { + id: string; + title: string; + objective: string; + status: RoadmapPhaseStatus; + brainstormIterations: number; + brainstormConverged: boolean; + validationFailures: number; + items: AutonomyRoadmapItem[]; +}; + +export type AutonomyRoadmap = { + id: string; + title: string; + sourceMarkdown: string; + status: "active" | "completed" | "blocked"; + currentPhaseIndex: number; + phases: AutonomyRoadmapPhase[]; + importedAt: string; + completedAt?: string; +}; + +export type AutonomyLease = { + token: string; + kind: AutonomyWorkKind; + actor: string; + runId?: string; + roadmapId: string; + phaseId: string; + itemId?: string; + createdAt: string; + expiresAt: string; +}; + +export type AutonomyCompletedLease = { + token: string; + outcome: AutonomyWorkOutcome; + summary: string; + addedTasks: string[]; + completedAt: string; +}; + +export type AutonomyRun = { + id: string; + actor: string; + startedAt: string; + reportedWorkItems: number; +}; + +export type AutonomyEvent = { + id: string; + at: string; + type: string; + actor: string; + summary: string; +}; + +export type AutonomyState = { + version: 1; + profile: AutonomyProfile; + enabled: boolean; + status: AutonomyStatus; + autoBrainstorm: boolean; + maxBrainstormIterations: number; + maxConsecutiveFailures: number; + leaseMinutes: number; + maxWorkItemsPerRun: number; + maxRunMinutes: number; + pauseAfterCurrent: boolean; + schedulingPolicy: "balanced" | "downstream-impact"; + roadmap?: AutonomyRoadmap; + activeLease?: AutonomyLease; + blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string }; + recoveryStartedAt?: string; + recoveryAttempts?: number; + maxRecoveryMinutes?: number; + runs: AutonomyRun[]; + completedLeases: AutonomyCompletedLease[]; + events: AutonomyEvent[]; + updatedAt: string; + updatedBy: string; +}; diff --git a/backend/src/routes/activity.ts b/backend/src/routes/activity.ts index d5a7701..df7de67 100644 --- a/backend/src/routes/activity.ts +++ b/backend/src/routes/activity.ts @@ -5,6 +5,7 @@ import { prisma } from "../db/prisma.js"; import { getRequestId } from "../plugins/requestId.js"; import { createGiteaActivityRuntimeLoader, isActiveRunnerJob, prioritizeRuntimeJobs, type CompactRunnerJob } from "../services/activityRuntime.js"; import { loadAutonomyRuntime } from "../services/autonomyRuntime.js"; +import { evaluateRunnerClaimHealth } from "../services/runnerClaimHealth.js"; import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; import { apiPaths } from "@projectbrain/contracts"; @@ -105,8 +106,10 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }), loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS), ]); + const claimHealth = evaluateRunnerClaimHealth(snapshot, { stallSeconds: options.env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS }); return successEnvelope({ ...snapshot, + claimHealth, worker: worker ? { status: Date.now() - worker.lastSeenAt.getTime() < 30_000 ? (worker.activeJobId ? "working" : worker.status) : "stale", activeJobId: worker.activeJobId ?? undefined, detail: worker.detail ?? undefined, lastSeenAt: worker.lastSeenAt.toISOString() } : undefined, autonomy, pollAfterMs: snapshot.counts.running || snapshot.counts.runnable || snapshot.counts.dependencyBlocked || autonomy.counts.activeProjects || autonomy.counts.claimableProjects ? 8_000 : 30_000, @@ -138,6 +141,7 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }), loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS), ]); + const claimHealth = evaluateRunnerClaimHealth(giteaRuntime, { stallSeconds: options.env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS }); const projectAutonomy = autonomy.projects.find((item) => item.projectId === project.id); const repositoryNames = new Set(project.giteaRepositories.map((link) => `${link.owner}/${link.repository}`.toLowerCase())); const projectJobs = giteaRuntime.jobs.filter((job) => job.repository && repositoryNames.has(job.repository.toLowerCase())).slice(0, 20); @@ -176,6 +180,7 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en const stateVersion = createHash("sha256").update(JSON.stringify({ items: items.map((item) => [item.id, item.status, item.createdAt]), gitea: giteaRuntime.stateVersion, + runnerClaimHealth: [claimHealth.status, claimHealth.coverage.complete, claimHealth.coverage.observed, claimHealth.coverage.total, claimHealth.runnerInventoryComplete, claimHealth.counts.pollingStalled, claimHealth.counts.labelBlocked, claimHealth.counts.unknown, claimHealth.oldestWaitSeconds, claimHealth.oldestClaimableWaitSeconds], worker: workerSnapshot ? [workerSnapshot.status, workerSnapshot.activeJobId, workerSnapshot.detail] : undefined, autonomy: projectAutonomy ? [projectAutonomy.status, projectAutonomy.pendingWorkCount, projectAutonomy.activeWorkKind, projectAutonomy.blockerKind, projectAutonomy.blockerReason, autonomy.executor?.status] : undefined, })).digest("hex").slice(0, 24); @@ -194,6 +199,7 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en activeJobCount: giteaRuntime.counts.running, queuedJobCount: giteaRuntime.counts.runnable, dependencyBlockedJobCount: giteaRuntime.counts.dependencyBlocked, + claimHealth, worker: workerSnapshot, autonomy: { executor: autonomy.executor, executors: autonomy.executors, project: projectAutonomy }, generatedAt: giteaRuntime.generatedAt, diff --git a/backend/src/routes/autonomy.ts b/backend/src/routes/autonomy.ts index c9c1cd5..1104788 100644 --- a/backend/src/routes/autonomy.ts +++ b/backend/src/routes/autonomy.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autonomyProfileSchema } from "@projectbrain/contracts"; import type { AppEnv } from "../config/env.js"; -import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork, resumeAutonomy } from "../repositories/autonomyRepository.js"; +import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reorderAutonomyQueue, reportAutonomyWork, resetAutonomyQueueOrder, resumeAutonomy } from "../repositories/autonomyRepository.js"; import { enqueueAutonomyTask } from "../services/autonomyAssignments.js"; import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js"; import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js"; @@ -28,6 +28,7 @@ const configSchema = z.object({ }).refine((value) => Object.keys(value).length > 0, "At least one Auto Mode setting is required."); const roadmapSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) }); const taskSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), instruction: z.string().trim().min(3).max(1_200) }); +const queueOrderSchema = z.object({ itemIds: z.array(idSchema).max(500) }); const claimSchema = z.object({ runId: z.string().trim().min(1).max(160).optional(), }).default({}); @@ -70,7 +71,7 @@ const actorFor = (request: FastifyRequest, fallback = "owner") => request.authUs const sendAutonomyError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => { const code = error instanceof Error ? error.message : "AUTO_MODE_OPERATION_FAILED"; - const conflictCodes = ["AUTO_MODE_DISABLED", "AUTO_MODE_ROADMAP_REQUIRED", "AUTO_MODE_LEASE_STALE", "AUTO_MODE_LEASE_OWNER_MISMATCH", "AUTO_MODE_ROADMAP_CHANGED", "AUTO_MODE_CONFLICT", "AUTO_MODE_DELIVERY_REQUIRED", "AUTO_MODE_DELIVERY_NOT_MERGED", "AUTO_MODE_DELIVERY_ALREADY_LINKED"]; + const conflictCodes = ["AUTO_MODE_DISABLED", "AUTO_MODE_ROADMAP_REQUIRED", "AUTO_MODE_LEASE_STALE", "AUTO_MODE_LEASE_OWNER_MISMATCH", "AUTO_MODE_ROADMAP_CHANGED", "AUTO_MODE_QUEUE_CHANGED", "AUTO_MODE_CONFLICT", "AUTO_MODE_DELIVERY_REQUIRED", "AUTO_MODE_DELIVERY_NOT_MERGED", "AUTO_MODE_DELIVERY_ALREADY_LINKED"]; const status = code === "PROJECT_NOT_FOUND" ? 404 : code === "AUTO_MODE_WORK_ACTIVE" || conflictCodes.includes(code) ? 409 : 400; const messages: Record = { PROJECT_NOT_FOUND: "Project not found.", @@ -80,6 +81,7 @@ const sendAutonomyError = (request: FastifyRequest, reply: FastifyReply, error: AUTO_MODE_LEASE_STALE: "This autonomous work lease is no longer active. Claim the current work item again.", AUTO_MODE_LEASE_OWNER_MISMATCH: "This autonomous work lease belongs to a different authenticated executor.", AUTO_MODE_ROADMAP_CHANGED: "The roadmap changed while this work item was active. Claim a fresh work item.", + AUTO_MODE_QUEUE_CHANGED: "De wachtrij is intussen gewijzigd. Herlaad de actuele volgorde en probeer opnieuw.", AUTO_MODE_CONFLICT: "Auto Mode state changed concurrently. Reload the state and retry the operation.", AUTO_MODE_DELIVERY_NOT_MERGED: "Implementation is unfinished. Complete exact-head validation and merge on the canonical Gitea execution branch before reporting success.", AUTO_MODE_DELIVERY_REQUIRED: "Implementation success requires reconciled Gitea validated-merge evidence or a backward-compatible ProjectBrain delivery record.", @@ -130,6 +132,21 @@ export const registerAutonomyRoutes = async (app: FastifyInstance, options: { da catch (error) { return sendAutonomyError(request, reply, error); } }); + app.put("/api/projects/:projectId/autonomy/queue", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const body = queueOrderSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues); + try { return successEnvelope(await reorderAutonomyQueue(parsed.projectId, body.data.itemIds, actorFor(request)), getRequestId(request)); } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + + app.delete("/api/projects/:projectId/autonomy/queue", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + try { return successEnvelope(await resetAutonomyQueueOrder(parsed.projectId, actorFor(request)), getRequestId(request)); } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + app.post("/api/projects/:projectId/autonomy/roadmap", async (request, reply) => { const parsed = params(request, reply); if (!parsed) return reply; if (requireDatabase(dataSource, request, reply)) return reply; diff --git a/backend/src/routes/giteaIntegration.ts b/backend/src/routes/giteaIntegration.ts index afc0f35..318617d 100644 --- a/backend/src/routes/giteaIntegration.ts +++ b/backend/src/routes/giteaIntegration.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import type { AppEnv } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { GiteaApiError } from "../integrations/gitea/client.js"; -import { assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js"; +import { assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, findReusableDeliveryPullRequest, successfulFullActionRun } from "../integrations/gitea/policy.js"; import { finishExternalOperation, getGiteaClient, getLinkedRepository, GiteaRepositoryOwnershipConflictError, GiteaRepositoryTransferBlockedError, linkRepository, listLinkedRepositories, listProjectRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, unlinkRepository, unlinkRepositoryById, type LinkedRepository } from "../integrations/gitea/service.js"; import type { GiteaContent } from "../integrations/gitea/types.js"; import { assertFeatureDeliveryActionEvidence, getFeatureDelivery, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery } from "../repositories/featureDeliveryRepository.js"; @@ -463,8 +463,8 @@ export const registerGiteaIntegrationRoutes = async (app: FastifyInstance, optio const link = await requireWorkbenchLink(projectId, false, delivery.spec.repositoryId); const client = getGiteaClient(env); const branch = assertProjectBrainBranch(delivery.spec.branch, link.defaultBranch); const head = await client.getBranch(link.owner, link.repository, branch); const response = await client.listActionRuns(link.owner, link.repository, { branch, headSha: head.commit.id, limit: 30 }); - const runs = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id); - const run = runs.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest); + assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id); + const run = successfulFullActionRun(response.workflow_runs, branch, head.commit.id); const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() }; return successEnvelope({ run, delivery: await recordFeatureDeliveryActionValidation(projectId, deliveryId, evidence, request.authUser?.username ?? "owner") }, getRequestId(request)); } catch (error) { const failure = integrationError(error); return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); } diff --git a/backend/src/routes/operatingSystem.ts b/backend/src/routes/operatingSystem.ts index dde726d..81a65a1 100644 --- a/backend/src/routes/operatingSystem.ts +++ b/backend/src/routes/operatingSystem.ts @@ -54,7 +54,7 @@ const requireOwner = (request: FastifyRequest, reply: FastifyReply) => isOwnerRe const operationError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => { const code = error instanceof Error ? error.message : "OPERATING_SYSTEM_OPERATION_FAILED"; - const status = code === "PROJECT_NOT_FOUND" || code.endsWith("NOT_FOUND") ? 404 : code.includes("STALE") || code.includes("CYCLE") ? 409 : 400; + const status = code === "PROJECT_NOT_FOUND" || code.endsWith("NOT_FOUND") ? 404 : code.includes("STALE") || code.includes("CYCLE") || code === "WORK_GRAPH_ACTIVE_LEASE" ? 409 : 400; return reply.status(status).send(errorEnvelope(code, "ProjectBrain could not complete this operating-system action.", getRequestId(request))); }; diff --git a/backend/src/services/activityRuntime.ts b/backend/src/services/activityRuntime.ts index 381e74e..7b19519 100644 --- a/backend/src/services/activityRuntime.ts +++ b/backend/src/services/activityRuntime.ts @@ -2,14 +2,18 @@ import { createHash } from "node:crypto"; import type { AppEnv } from "../config/env.js"; import { getGiteaClient } from "../integrations/gitea/service.js"; import type { GiteaActionJob, GiteaActionRunner } from "../integrations/gitea/types.js"; +import { hasCompatibleIdleRunner } from "./runnerCompatibility.js"; const RUNNING_JOB_STATUSES = new Set(["in_progress", "running"]); const RUNNABLE_JOB_STATUSES = new Set(["queued"]); -const DEPENDENCY_BLOCKED_JOB_STATUSES = new Set(["pending", "waiting"]); +const DEPENDENCY_BLOCKED_JOB_STATUSES = new Set(["pending", "waiting", "requested"]); const ACTIVE_JOB_STATUSES = new Set([...RUNNING_JOB_STATUSES, ...RUNNABLE_JOB_STATUSES, ...DEPENDENCY_BLOCKED_JOB_STATUSES]); const ZERO_DATE_PREFIX = "1970-01-01"; const FRESH_CACHE_MS = 15_000; const ERROR_CACHE_MS = 30_000; +const MAX_HEALTH_SAMPLE_GAP_MS = 45_000; +const RUNNABLE_JOB_PAGE_LIMIT = 50; +const MAX_RUNNABLE_JOB_PAGES = 10; export type CompactRunner = { id: number; @@ -39,6 +43,14 @@ export type CompactRunnerJob = { createdAt: string; startedAt?: string; completedAt?: string; + runnableObservedSince?: string; + claimableObservedSince?: string; +}; + +export type RunnableJobScan = { + complete: boolean; + observed: number; + total: number | null; }; export type GiteaRuntimeSnapshot = { @@ -50,18 +62,80 @@ export type GiteaRuntimeSnapshot = { runnable: number; dependencyBlocked: number; }; + runnableJobScan: RunnableJobScan; + runnerInventoryComplete: boolean; generatedAt: string; stateVersion: string; warning?: string; }; -export const prioritizeRuntimeJobs = >(jobs: T[]): T[] => { +const runtimeJobTimestamp = (job: Pick) => { + const value = job.queueState === "running" ? job.startedAt ?? job.createdAt : job.createdAt; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Number.MAX_SAFE_INTEGER; +}; + +export const prioritizeRuntimeJobs = >(jobs: T[]): T[] => { const rank = { running: 0, runnable: 1, "dependency-blocked": 2, completed: 3 }; - return [...jobs].sort((a, b) => rank[a.queueState] - rank[b.queueState] || b.id - a.id); + return [...jobs].sort((a, b) => { + const rankDifference = rank[a.queueState] - rank[b.queueState]; + if (rankDifference) return rankDifference; + const timeDifference = runtimeJobTimestamp(a) - runtimeJobTimestamp(b); + if (a.queueState === "runnable") return timeDifference || a.id - b.id; + return a.queueState === "completed" + ? (timeDifference ? -timeDifference : b.id - a.id) + : (timeDifference || b.id - a.id); + }); }; type RuntimeClient = Pick, "listUserActionRunners" | "listUserActionJobs">; +type RunnableJobScanResult = { + jobs: GiteaActionJob[]; + totalCount: number | null; + complete: boolean; + warning?: string; +}; + +const scanRunnableJobs = async (client: RuntimeClient): Promise => { + const firstPage = await client.listUserActionJobs({ status: "queued", limit: RUNNABLE_JOB_PAGE_LIMIT, page: 1 }); + const totalCount = Number.isSafeInteger(firstPage.total_count) && firstPage.total_count >= 0 ? firstPage.total_count : null; + const jobs = new Map(firstPage.jobs.map((job) => [job.id, job])); + let consistent = totalCount !== null + && firstPage.jobs.length === Math.min(totalCount, RUNNABLE_JOB_PAGE_LIMIT) + && jobs.size === firstPage.jobs.length; + let warning: string | undefined = totalCount === null ? "Gitea returned an invalid queued-job total." : undefined; + + if (totalCount !== null && totalCount > 0 && firstPage.jobs.length === 0) { + consistent = false; + warning = "Gitea returned an empty queued-job page while reporting queued jobs."; + } + + const pageCount = totalCount === null ? 1 : Math.min(MAX_RUNNABLE_JOB_PAGES, Math.ceil(totalCount / RUNNABLE_JOB_PAGE_LIMIT)); + for (let page = 2; totalCount !== null && page <= pageCount && jobs.size < totalCount; page += 1) { + let response; + try { + response = await client.listUserActionJobs({ status: "queued", limit: RUNNABLE_JOB_PAGE_LIMIT, page }); + } catch (error) { + warning = error instanceof Error ? error.message : "A queued-job page could not be read."; + consistent = false; + break; + } + const expectedPageSize = Math.min(RUNNABLE_JOB_PAGE_LIMIT, totalCount - ((page - 1) * RUNNABLE_JOB_PAGE_LIMIT)); + if (response.total_count !== totalCount || response.jobs.length !== expectedPageSize) consistent = false; + for (const job of response.jobs) { + if (jobs.has(job.id)) consistent = false; + jobs.set(job.id, job); + } + } + + const complete = totalCount !== null + && consistent + && totalCount <= RUNNABLE_JOB_PAGE_LIMIT * MAX_RUNNABLE_JOB_PAGES + && jobs.size === totalCount; + return { jobs: [...jobs.values()], totalCount, complete, ...(warning ? { warning } : {}) }; +}; + const validDate = (value?: string) => value && !value.startsWith(ZERO_DATE_PREFIX) ? value : undefined; const queueStateFor = (status: string): CompactRunnerJob["queueState"] => { @@ -122,6 +196,9 @@ export const compactGiteaRuntime = ( jobs: GiteaActionJob[], generatedAt: string, exactCounts?: GiteaRuntimeSnapshot["counts"], + runnableJobScan?: RunnableJobScan, + runnerInventoryComplete = true, + previousSnapshot?: GiteaRuntimeSnapshot, ): GiteaRuntimeSnapshot => { const allCompactJobs = jobs.map(compactJob); const compactJobs = [ @@ -143,14 +220,60 @@ export const compactGiteaRuntime = ( labels: job.labels, }); } + const counts = exactCounts ?? { + running: compactJobs.filter((job) => job.queueState === "running").length, + runnable: compactJobs.filter((job) => job.queueState === "runnable").length, + dependencyBlocked: compactJobs.filter((job) => job.queueState === "dependency-blocked").length, + }; + const scan = runnableJobScan ?? { + complete: true, + observed: compactJobs.filter((job) => job.queueState === "runnable").length, + total: counts.runnable, + }; + const snapshotTime = Date.parse(generatedAt); + const previousTime = previousSnapshot ? Date.parse(previousSnapshot.generatedAt) : Number.NaN; + const sampleGap = snapshotTime - previousTime; + const canCarryRunnableObservation = Boolean( + scan.complete + && previousSnapshot?.availability === "live" + && previousSnapshot.runnableJobScan.complete + && Number.isFinite(sampleGap) + && sampleGap >= 0 + && sampleGap <= MAX_HEALTH_SAMPLE_GAP_MS, + ); + const previousRunnableJobs = new Map( + canCarryRunnableObservation + ? previousSnapshot!.jobs.filter((job) => job.queueState === "runnable").map((job) => [job.id, job]) + : [], + ); + const jobsWithObservations = compactJobs.map((job) => { + if (job.queueState !== "runnable" || !scan.complete) return job; + const previous = previousRunnableJobs.get(job.id); + const sameRunnableJob = previous + && previous.runId === job.runId + && previous.createdAt === job.createdAt + && previous.status === job.status; + const runnableObservedSince = sameRunnableJob + ? previous.runnableObservedSince ?? previousSnapshot!.generatedAt + : generatedAt; + const idleCompatible = runnerInventoryComplete && hasCompatibleIdleRunner([...runnerMap.values()], job); + const claimableObservedSince = idleCompatible + ? sameRunnableJob && previous.claimableObservedSince + ? previous.claimableObservedSince + : generatedAt + : undefined; + return { + ...job, + runnableObservedSince, + ...(claimableObservedSince ? { claimableObservedSince } : {}), + }; + }); const compact = { runners: [...runnerMap.values()].sort((left, right) => left.name.localeCompare(right.name)), - jobs: compactJobs, - counts: exactCounts ?? { - running: compactJobs.filter((job) => job.queueState === "running").length, - runnable: compactJobs.filter((job) => job.queueState === "runnable").length, - dependencyBlocked: compactJobs.filter((job) => job.queueState === "dependency-blocked").length, - }, + jobs: jobsWithObservations, + counts, + runnableJobScan: scan, + runnerInventoryComplete, }; const stateVersion = createHash("sha256").update(JSON.stringify(compact)).digest("hex").slice(0, 24); return { availability: "live", ...compact, generatedAt, stateVersion }; @@ -176,6 +299,8 @@ export const createGiteaActivityRuntimeLoader = ( runners: [], jobs: [], counts: { running: 0, runnable: 0, dependencyBlocked: 0 }, + runnableJobScan: { complete: false, observed: 0, total: null }, + runnerInventoryComplete: false, generatedAt: checkedAt.toISOString(), stateVersion: "gitea-runtime-unavailable", warning: "Gitea runtime monitoring is not configured.", @@ -187,7 +312,7 @@ export const createGiteaActivityRuntimeLoader = ( inFlight = Promise.allSettled([ client.listUserActionRunners(), - client.listUserActionJobs({ status: "queued", limit: 50 }), + scanRunnableJobs(client), client.listUserActionJobs({ status: "pending", limit: 50 }), client.listUserActionJobs({ status: "in_progress", limit: 50 }), client.listUserActionJobs({ limit: 30 }), @@ -212,14 +337,19 @@ export const createGiteaActivityRuntimeLoader = ( const derived = compactGiteaRuntime([], jobs, checkedAt.toISOString()).counts; const snapshot = compactGiteaRuntime(runners, jobs, checkedAt.toISOString(), { running: runningResponse?.total_count ?? derived.running, - runnable: runnableResponse?.total_count ?? derived.runnable, + runnable: runnableResponse?.totalCount ?? derived.runnable, dependencyBlocked: dependencyResponse?.total_count ?? derived.dependencyBlocked, - }); + }, { + complete: runnableResponse?.complete ?? false, + observed: runnableResponse?.jobs.length ?? 0, + total: runnableResponse?.totalCount ?? null, + }, runnerResult.status === "fulfilled", cached); const warnings = [runnerResult, ...jobResults] .filter((result) => result.status === "rejected") .map((result) => result.status === "rejected" ? result.reason instanceof Error ? result.reason.message : String(result.reason) : ""); + if (runnableResponse?.warning) warnings.push(runnableResponse.warning); cached = warnings.length ? { ...snapshot, warning: `Partial Gitea monitoring failure: ${[...new Set(warnings)].join("; ")}` } : snapshot; cachedAt = checkedAt.getTime(); return cached; @@ -233,6 +363,8 @@ export const createGiteaActivityRuntimeLoader = ( runners: [], jobs: [], counts: { running: 0, runnable: 0, dependencyBlocked: 0 }, + runnableJobScan: { complete: false, observed: 0, total: null }, + runnerInventoryComplete: false, generatedAt: checkedAt.toISOString(), stateVersion: createHash("sha256").update(warning).digest("hex").slice(0, 24), warning, diff --git a/backend/src/services/autonomyQueueOrdering.ts b/backend/src/services/autonomyQueueOrdering.ts new file mode 100644 index 0000000..5df14ed --- /dev/null +++ b/backend/src/services/autonomyQueueOrdering.ts @@ -0,0 +1,42 @@ +import type { AutonomyRoadmapItem, AutonomyState } from "../repositories/autonomyTypes.js"; + +const isManuallyReorderableItem = (item: AutonomyRoadmapItem) => + item.status === "pending" && item.scope !== "future" && item.scope !== "stretch"; + +export const nextManualOrderForAutonomyState = (state: AutonomyState): number | undefined => { + const orders = state.roadmap?.phases + .flatMap((phase) => phase.items) + .flatMap((item) => typeof item.manualOrder === "number" ? [item.manualOrder] : []) ?? []; + return orders.length ? Math.max(...orders) + 1 : undefined; +}; + +export const applyAutonomyQueueOrder = (current: AutonomyState, orderedItemIds: string[]): AutonomyState => { + if (!current.roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED"); + const next = structuredClone(current) as AutonomyState; + const roadmap = next.roadmap; + if (!roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED"); + const reorderable = roadmap.phases.flatMap((phase) => phase.items).filter(isManuallyReorderableItem); + const expectedIds = new Set(reorderable.map((item) => item.id)); + const requestedIds = new Set(orderedItemIds); + if (orderedItemIds.length !== reorderable.length || requestedIds.size !== orderedItemIds.length + || requestedIds.size !== expectedIds.size || orderedItemIds.some((id) => !expectedIds.has(id))) { + throw new Error("AUTO_MODE_QUEUE_CHANGED"); + } + const positionById = new Map(orderedItemIds.map((id, index) => [id, index])); + for (const phase of roadmap.phases) { + for (const item of phase.items) { + if (isManuallyReorderableItem(item)) item.manualOrder = positionById.get(item.id); + else delete item.manualOrder; + } + } + return next; +}; + +export const clearAutonomyQueueOrder = (current: AutonomyState): AutonomyState => { + if (!current.roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED"); + const next = structuredClone(current) as AutonomyState; + const roadmap = next.roadmap; + if (!roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED"); + for (const phase of roadmap.phases) for (const item of phase.items) delete item.manualOrder; + return next; +}; diff --git a/backend/src/services/autonomyRuntime.ts b/backend/src/services/autonomyRuntime.ts index bea98cb..ffd1431 100644 --- a/backend/src/services/autonomyRuntime.ts +++ b/backend/src/services/autonomyRuntime.ts @@ -176,6 +176,7 @@ export const compactAutonomyState = (state: AutonomyState, options: CompactAuton title: clip(item.title, 500), status: item.status, attempts: item.attempts, + manualOrder: item.manualOrder, source: item.source, deliveryId: item.deliveryId, dependsOn: item.dependsOn, diff --git a/backend/src/services/autonomyScheduler.ts b/backend/src/services/autonomyScheduler.ts index 6a138ba..39d0e4f 100644 --- a/backend/src/services/autonomyScheduler.ts +++ b/backend/src/services/autonomyScheduler.ts @@ -85,6 +85,17 @@ export const scoreAutonomyItem = ( - estimatedScopePenalty; }; +const compareAutonomyCandidates = (left: AutonomyScheduleCandidate, right: AutonomyScheduleCandidate) => { + const leftManual = left.item.manualOrder; + const rightManual = right.item.manualOrder; + if (typeof leftManual === "number" || typeof rightManual === "number") { + if (typeof leftManual !== "number") return 1; + if (typeof rightManual !== "number") return -1; + if (leftManual !== rightManual) return leftManual - rightManual; + } + return right.score - left.score || right.downstreamCount - left.downstreamCount || left.phaseIndex - right.phaseIndex || left.item.id.localeCompare(right.item.id); +}; + export const rankAutonomyItems = ( items: AutonomyRoadmapItem[], phaseIndex = 0, @@ -100,7 +111,7 @@ export const rankAutonomyItems = ( downstreamCount: downstreamCountForAutonomyItem(item, items), conflictKeys: conflictKeysForAutonomyItem(item), })) - .sort((left, right) => right.score - left.score || right.downstreamCount - left.downstreamCount || left.item.id.localeCompare(right.item.id)); + .sort(compareAutonomyCandidates); export const chooseNextAutonomyWork = ( phases: AutonomyRoadmapPhase[], @@ -118,7 +129,7 @@ export const chooseNextAutonomyWork = ( downstreamCount: downstreamCountForAutonomyItem(item, allItems), conflictKeys: conflictKeysForAutonomyItem(item), }))); - return candidates.sort((left, right) => right.score - left.score || right.downstreamCount - left.downstreamCount || left.phaseIndex - right.phaseIndex || left.item.id.localeCompare(right.item.id))[0]; + return candidates.sort(compareAutonomyCandidates)[0]; }; export const planParallelAutonomyWork = (candidates: AutonomyScheduleCandidate[]) => { diff --git a/backend/src/services/runnerClaimHealth.ts b/backend/src/services/runnerClaimHealth.ts new file mode 100644 index 0000000..20c2e65 --- /dev/null +++ b/backend/src/services/runnerClaimHealth.ts @@ -0,0 +1,137 @@ +import type { CompactRunner, CompactRunnerJob, GiteaRuntimeSnapshot } from "./activityRuntime.js"; +import { isOnlineRunner, runnerMatchesJob } from "./runnerCompatibility.js"; + +export type RunnerClaimState = "claimable" | "capacity-wait" | "label-blocked" | "polling-stalled" | "unknown"; + +export type RunnerClaimDiagnosis = { + jobId: number; + jobName: string; + repository?: string; + waitSeconds: number; + claimableWaitSeconds: number; + state: RunnerClaimState; + requiredLabels: string[]; + compatibleRunnerNames: string[]; + compatibleIdleRunnerNames: string[]; +}; + +export type RunnerClaimHealth = { + status: "healthy" | "degraded" | "stalled" | "incomplete"; + thresholdSeconds: number; + generatedAt: string; + oldestWaitSeconds: number; + oldestClaimableWaitSeconds: number; + coverage: { + complete: boolean; + observed: number; + total: number | null; + }; + runnerInventoryComplete: boolean; + recoveryOwner: "appops-runner-fleet"; + recoveryPolicy: "controller-owned"; + counts: { + queued: number; + claimable: number; + capacityWait: number; + labelBlocked: number; + pollingStalled: number; + unknown: number; + }; + jobs: RunnerClaimDiagnosis[]; +}; + +const waitSecondsFor = (job: CompactRunnerJob, now: number) => { + const observedSince = Date.parse(job.runnableObservedSince ?? ""); + if (!Number.isFinite(observedSince) || observedSince <= 0 || observedSince > now) return 0; + return Math.max(0, Math.floor((now - observedSince) / 1_000)); +}; + +const claimableWaitSecondsFor = (job: CompactRunnerJob, now: number) => { + const observedSince = Date.parse(job.claimableObservedSince ?? ""); + if (!Number.isFinite(observedSince) || observedSince <= 0 || observedSince > now) return 0; + return Math.max(0, Math.floor((now - observedSince) / 1_000)); +}; + +export const evaluateRunnerClaimHealth = ( + runtime: Pick & Partial>, + options: { stallSeconds?: number; now?: number } = {}, +): RunnerClaimHealth => { + const requestedNow = options.now ?? Date.now(); + const sampleTime = Date.parse(runtime.generatedAt); + const now = Number.isFinite(sampleTime) && sampleTime > 0 ? Math.min(requestedNow, sampleTime) : 0; + const thresholdSeconds = Math.max(30, Math.min(3_600, Math.trunc(options.stallSeconds ?? 120))); + const runners = runtime.runners.filter(isOnlineRunner); + const queued = runtime.jobs.filter((job) => job.queueState === "runnable"); + const runnerInventoryComplete = runtime.runnerInventoryComplete !== false + && runtime.availability !== "stale" + && runtime.availability !== "unavailable"; + const coverage = runtime.runnableJobScan + ? { + complete: runtime.availability !== "stale" && runtime.availability !== "unavailable" && runtime.runnableJobScan.complete, + observed: runtime.runnableJobScan.observed, + total: runtime.runnableJobScan.total, + } + : { complete: true, observed: queued.length, total: queued.length }; + + const diagnoses = queued.map((job): RunnerClaimDiagnosis => { + if (!runnerInventoryComplete) { + return { + jobId: job.id, + jobName: job.name, + ...(job.repository ? { repository: job.repository } : {}), + waitSeconds: waitSecondsFor(job, now), + claimableWaitSeconds: 0, + state: "unknown", + requiredLabels: [...job.labels], + compatibleRunnerNames: [], + compatibleIdleRunnerNames: [], + }; + } + const compatible = runners.filter((runner) => runnerMatchesJob(runner, job)); + const idleCompatible = compatible.filter((runner) => !runner.busy); + const waitSeconds = waitSecondsFor(job, now); + const claimableWaitSeconds = idleCompatible.length ? claimableWaitSecondsFor(job, now) : 0; + const state: RunnerClaimState = !compatible.length + ? "label-blocked" + : !idleCompatible.length + ? "capacity-wait" + : coverage.complete && claimableWaitSeconds >= thresholdSeconds + ? "polling-stalled" + : "claimable"; + return { + jobId: job.id, + jobName: job.name, + ...(job.repository ? { repository: job.repository } : {}), + waitSeconds, + claimableWaitSeconds, + state, + requiredLabels: [...job.labels], + compatibleRunnerNames: compatible.map((runner) => runner.name), + compatibleIdleRunnerNames: idleCompatible.map((runner) => runner.name), + }; + }).sort((left, right) => right.waitSeconds - left.waitSeconds || left.jobId - right.jobId); + + const counts = { + queued: diagnoses.length, + claimable: diagnoses.filter((entry) => entry.state === "claimable").length, + capacityWait: diagnoses.filter((entry) => entry.state === "capacity-wait").length, + labelBlocked: diagnoses.filter((entry) => entry.state === "label-blocked").length, + pollingStalled: diagnoses.filter((entry) => entry.state === "polling-stalled").length, + unknown: diagnoses.filter((entry) => entry.state === "unknown").length, + }; + return { + status: !coverage.complete || !runnerInventoryComplete + ? "incomplete" + : counts.pollingStalled ? "stalled" : counts.labelBlocked ? "degraded" : "healthy", + thresholdSeconds, + generatedAt: runtime.generatedAt, + oldestWaitSeconds: diagnoses.reduce((oldest, entry) => Math.max(oldest, entry.waitSeconds), 0), + oldestClaimableWaitSeconds: diagnoses.reduce((oldest, entry) => Math.max(oldest, entry.claimableWaitSeconds), 0), + coverage, + runnerInventoryComplete, + recoveryOwner: "appops-runner-fleet", + recoveryPolicy: "controller-owned", + counts, + jobs: diagnoses.slice(0, 50), + }; +}; diff --git a/backend/src/services/runnerCompatibility.ts b/backend/src/services/runnerCompatibility.ts new file mode 100644 index 0000000..e8bd5ec --- /dev/null +++ b/backend/src/services/runnerCompatibility.ts @@ -0,0 +1,17 @@ +type RunnerCompatibilityShape = { disabled: boolean; status: string; busy: boolean; labels: string[] }; +type JobLabelShape = { labels: string[] }; + +export const isOnlineRunner = (runner: RunnerCompatibilityShape) => + !runner.disabled && (runner.status === "online" || runner.status === "observed"); + +const normalizeLabel = (value: string) => value.trim().toLowerCase(); + +export const runnerMatchesJob = (runner: RunnerCompatibilityShape, job: JobLabelShape) => { + const required = job.labels.map(normalizeLabel).filter(Boolean); + if (!required.length) return true; + const available = new Set(runner.labels.map(normalizeLabel).filter(Boolean)); + return required.every((label) => available.has(label)); +}; + +export const hasCompatibleIdleRunner = (runners: RunnerCompatibilityShape[], job: JobLabelShape) => + runners.some((runner) => isOnlineRunner(runner) && !runner.busy && runnerMatchesJob(runner, job)); diff --git a/backend/src/services/workGraph.ts b/backend/src/services/workGraph.ts index 62134d2..0544fb4 100644 --- a/backend/src/services/workGraph.ts +++ b/backend/src/services/workGraph.ts @@ -256,8 +256,19 @@ const reviewProjection = (project: { reviewQueueItems: Array<{ id: string; title metadataJson: json({ source: "review_queue" }), })); -const findProject = async (projectIdOrSlug: string) => { +type WorkGraphClient = Prisma.TransactionClient | typeof prisma; + +const resolveProjectId = async (projectIdOrSlug: string) => { const project = await prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, + select: { id: true }, + }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + return project.id; +}; + +const findProject = async (projectIdOrSlug: string, client: WorkGraphClient = prisma) => { + const project = await client.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, select: { id: true, @@ -275,10 +286,10 @@ const findProject = async (projectIdOrSlug: string) => { return project; }; -const upsertProjection = async (projectId: string, projection: Projection) => { +const upsertProjection = async (client: Prisma.TransactionClient, projectId: string, projection: Projection, now: Date) => { const { supersededByStableKey: _supersededByStableKey, ...persistedProjection } = projection; - const existing = await prisma.workGraphNode.findUnique({ where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } }, select: { owner: true, leaseExpiresAt: true, executionEligibility: true, completedAt: true, domainLifecycle: true, scope: true } }); - const hasLiveLease = Boolean(existing?.owner && existing.leaseExpiresAt && existing.leaseExpiresAt.getTime() > Date.now() && existing.executionEligibility === "running"); + const existing = await client.workGraphNode.findUnique({ where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } }, select: { owner: true, leaseExpiresAt: true, executionEligibility: true, completedAt: true, domainLifecycle: true, scope: true } }); + const hasLiveLease = Boolean(existing?.owner && existing.leaseExpiresAt && existing.leaseExpiresAt.getTime() > now.getTime() && existing.executionEligibility === "running"); // A completed graph node is durable evidence, but it must not hide an // authoritative source reopening the work. Preserve completion only while // the source lifecycle still describes the same completed reality. @@ -295,35 +306,42 @@ const upsertProjection = async (projectId: string, projection: Projection) => { && ["ready", "running"].includes(existing?.executionEligibility ?? "") && ["ready", "running"].includes(projection.executionEligibility); const preserveOperationalState = hasLiveLease || hasDurableCompletion || sameActiveFamily; - return prisma.workGraphNode.upsert({ + return client.workGraphNode.upsert({ where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } }, create: { projectId, ...persistedProjection, ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" ? { completedAt: new Date() } : {}) }, update: { source: persistedProjection.source, sourceId: persistedProjection.sourceId, type: persistedProjection.type, title: persistedProjection.title, domainLifecycle: persistedProjection.domainLifecycle, priority: persistedProjection.priority, phase: persistedProjection.phase ?? null, risk: persistedProjection.risk, ...(preserveOperationalState ? { scope: persistedProjection.scope } : { canonicalLifecycle: persistedProjection.canonicalLifecycle, scope: persistedProjection.scope, blockingStatus: persistedProjection.blockingStatus, executionEligibility: persistedProjection.executionEligibility, blockReason: persistedProjection.blockReason ?? null }), ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" && !existing?.completedAt ? { completedAt: new Date() } : {}) }, }); }; -export const syncProjectWorkGraph = async (projectIdOrSlug: string, actor = "system", options: { audit?: boolean } = {}) => { - const project = await findProject(projectIdOrSlug); +const lockWorkGraphProject = async (transaction: Prisma.TransactionClient, projectId: string) => { + // This still serializes work-graph writers while remaining compatible with + // the KEY SHARE lock that the existing node-first completion path takes + // when it inserts audit and evidence rows referencing the Project. + const locked = await transaction.$queryRaw>`SELECT "id" FROM "Project" WHERE "id" = ${projectId} AND "deletedAt" IS NULL FOR NO KEY UPDATE`; + if (locked.length !== 1) throw new Error("PROJECT_NOT_FOUND"); +}; + +const reconcileProjectWorkGraph = async (transaction: Prisma.TransactionClient, project: Awaited>, actor: string, now: Date, options: { audit?: boolean } = {}) => { const autonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy); const autonomyRows = autonomyProjection(project.id, autonomy); const projections = [...autonomyRows.rows, ...deliveryProjection(project), ...findingProjection(project), ...initiativeProjection(project), ...reviewProjection(project), ...featureProjection(project), ...brainstormProjection(project)]; const projectionKeys = new Set(projections.map((projection) => projection.stableKey)); const nodes = new Map(); for (const projection of projections) { - const node = await upsertProjection(project.id, projection); + const node = await upsertProjection(transaction, project.id, projection, now); nodes.set(projection.stableKey, node); } for (const projection of projections) { if (!projection.supersededByStableKey) continue; const node = nodes.get(projection.stableKey); const replacement = nodes.get(projection.supersededByStableKey) ?? nodes.get(`autonomy:${projection.supersededByStableKey.replace(/^delivery:/, "")}`); - if (node && replacement && node.id !== replacement.id) await prisma.workGraphNode.update({ where: { id: node.id }, data: { supersededById: replacement.id } }); + if (node && replacement && node.id !== replacement.id) await transaction.workGraphNode.update({ where: { id: node.id }, data: { supersededById: replacement.id } }); } - const staleNodes = await prisma.workGraphNode.findMany({ where: { projectId: project.id }, select: { id: true, stableKey: true, title: true, executionEligibility: true } }); + const staleNodes = await transaction.workGraphNode.findMany({ where: { projectId: project.id }, select: { id: true, stableKey: true, title: true, executionEligibility: true } }); for (const stale of staleNodes) { if (projectionKeys.has(stale.stableKey) || ["done", "superseded"].includes(stale.executionEligibility)) continue; - await prisma.workGraphNode.update({ where: { id: stale.id }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "superseded", canonicalLifecycle: "superseded", blockingStatus: "closed", blockReason: "The authoritative source no longer exposes this work item; retained history is preserved as superseded." } }); - await prisma.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: stale.id, eventType: "work.superseded", actor, summary: `Superseded stale work '${stale.title}'.`, payloadJson: json({ stableKey: stale.stableKey }) } }); + await transaction.workGraphNode.update({ where: { id: stale.id }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "superseded", canonicalLifecycle: "superseded", blockingStatus: "closed", blockReason: "The authoritative source no longer exposes this work item; retained history is preserved as superseded." } }); + await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: stale.id, eventType: "work.superseded", actor, summary: `Superseded stale work '${stale.title}'.`, payloadJson: json({ stableKey: stale.stableKey }) } }); } const dependencyPairs = [...autonomyRows.dependencies]; for (const delivery of project.deliveryPlans) if (delivery.initiativeId) dependencyPairs.push({ nodeKey: `delivery:${delivery.id}`, dependencyKey: `initiative:${delivery.initiativeId}`, type: "context" }); @@ -331,21 +349,37 @@ export const syncProjectWorkGraph = async (projectIdOrSlug: string, actor = "sys const investigation = finding.investigations.find((candidate) => candidate.deliveryPlanId); if (investigation?.deliveryPlanId) dependencyPairs.push({ nodeKey: `finding:${finding.id}`, dependencyKey: `delivery:${investigation.deliveryPlanId}`, type: "remediation" }); } + const liveLeaseNodes = await transaction.workGraphNode.findMany({ + where: { projectId: project.id, executionEligibility: "running", owner: { not: null }, leaseExpiresAt: { gt: now } }, + select: { id: true }, + }); + const liveLeaseNodeIds = new Set(liveLeaseNodes.map((node) => node.id)); // Rebuild only derived edges. Explicit human dependency edits are durable - // graph data and must survive every reconciliation pass. - await prisma.workGraphDependency.deleteMany({ where: { projectId: project.id, createdBy: "autonomy-sync" } }); + // graph data and must survive every reconciliation pass. A running lease + // keeps the dependency set it was claimed against until the lease expires. + await transaction.workGraphDependency.deleteMany({ where: { projectId: project.id, createdBy: "autonomy-sync", ...(liveLeaseNodeIds.size ? { nodeId: { notIn: [...liveLeaseNodeIds] } } : {}) } }); + const derivedEdges: Prisma.WorkGraphDependencyCreateManyInput[] = []; for (const pair of dependencyPairs) { const node = nodes.get(pair.nodeKey); const dependency = nodes.get(pair.dependencyKey); - if (!node || !dependency || node.id === dependency.id) continue; - await prisma.workGraphDependency.create({ data: { projectId: project.id, nodeId: node.id, dependsOnNodeId: dependency.id, dependencyType: pair.type, createdBy: "autonomy-sync" } }).catch((error: unknown) => { - if ((error as { code?: string }).code !== "P2002") throw error; - }); + if (!node || !dependency || node.id === dependency.id || liveLeaseNodeIds.has(node.id)) continue; + derivedEdges.push({ projectId: project.id, nodeId: node.id, dependsOnNodeId: dependency.id, dependencyType: pair.type, createdBy: "autonomy-sync" }); } - if (options.audit) await prisma.workGraphAuditEvent.create({ data: { projectId: project.id, eventType: "graph.reconciled", actor, summary: `Reconciled ${projections.length} cross-domain work node(s).`, payloadJson: json({ nodeCount: projections.length, dependencyCount: dependencyPairs.length }) } }); + if (derivedEdges.length) await transaction.workGraphDependency.createMany({ data: derivedEdges, skipDuplicates: true }); + if (options.audit) await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, eventType: "graph.reconciled", actor, summary: `Reconciled ${projections.length} cross-domain work node(s).`, payloadJson: json({ nodeCount: projections.length, dependencyCount: dependencyPairs.length }) } }); return project.id; }; +export const syncProjectWorkGraph = async (projectIdOrSlug: string, actor = "system", options: { audit?: boolean } = {}) => { + const projectId = await resolveProjectId(projectIdOrSlug); + return prisma.$transaction(async (transaction) => { + await lockWorkGraphProject(transaction, projectId); + const now = new Date(); + const currentProject = await findProject(projectId, transaction); + return reconcileProjectWorkGraph(transaction, currentProject, actor, now, options); + }); +}; + const nodeView = (node: WorkGraphNodeRecord): WorkGraphNodeView => ({ id: node.id, projectId: node.projectId, @@ -391,18 +425,19 @@ const nodeView = (node: WorkGraphNodeRecord): WorkGraphNodeView => ({ const loadNodes = async (projectId: string) => prisma.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true, dependents: true, evidence: true }, orderBy: [{ executionEligibility: "asc" }, { priority: "desc" }, { updatedAt: "desc" }] }); -const deriveNodes = (rawNodes: WorkGraphNodeView[]) => rawNodes.map((node) => { - if (node.executionEligibility === "running" && (!node.leaseExpiresAt || Date.parse(node.leaseExpiresAt) <= Date.now())) { - const { owner: _owner, leaseExpiresAt: _leaseExpiresAt, ...reclaimable } = node; - return { ...reclaimable, executionEligibility: "ready" as const, canonicalLifecycle: "ready", blockReason: "Previous lease expired; work can be safely reclaimed." }; - } - if (node.executionEligibility === "done" || node.executionEligibility === "running" || node.executionEligibility === "needs_user" || node.executionEligibility === "superseded") return node; - if (node.scope === "future" || node.scope === "stretch") return { ...node, executionEligibility: "blocked" as const, blockReason: node.blockReason ?? "Outside Required + Committed scope until explicitly promoted." }; - const blockedBy = node.dependencies.filter((edge) => { +const deriveNodes = (rawNodes: WorkGraphNodeView[], now = new Date()) => rawNodes.map((node) => { + const leaseExpired = node.executionEligibility === "running" && (!node.leaseExpiresAt || Date.parse(node.leaseExpiresAt) <= now.getTime()); + const { owner: _owner, leaseExpiresAt: _leaseExpiresAt, ...expiredNode } = node; + const candidate = leaseExpired + ? { ...expiredNode, executionEligibility: "ready" as const, canonicalLifecycle: "ready", blockReason: "Previous lease expired; work can be safely reclaimed." } + : node; + if (candidate.executionEligibility === "done" || candidate.executionEligibility === "running" || candidate.executionEligibility === "needs_user" || candidate.executionEligibility === "superseded") return candidate; + if (candidate.scope === "future" || candidate.scope === "stretch") return { ...candidate, executionEligibility: "blocked" as const, blockReason: candidate.blockReason ?? "Outside Required + Committed scope until explicitly promoted." }; + const blockedBy = candidate.dependencies.filter((edge) => { const dependency = rawNodes.find((candidate) => candidate.id === edge.dependsOnNodeId); return dependency && dependency.executionEligibility !== "done" && dependency.canonicalLifecycle !== "done"; }); - return blockedBy.length ? { ...node, executionEligibility: "waiting" as const, blockingStatus: "dependency" } : node; + return blockedBy.length ? { ...candidate, executionEligibility: "waiting" as const, blockingStatus: "dependency" } : candidate; }); const genericScore = (node: WorkGraphNodeView, all: WorkGraphNodeView[]) => { @@ -481,21 +516,23 @@ const assertProjectNode = async (projectIdOrSlug: string, nodeId: string) => { }; export const claimReadyWork = async (projectIdOrSlug: string, actor: string, leaseMinutes = 30) => { - const project = await findProject(projectIdOrSlug); - await syncProjectWorkGraph(project.id, actor); - const now = new Date(); + const projectId = await resolveProjectId(projectIdOrSlug); return prisma.$transaction(async (transaction) => { + await lockWorkGraphProject(transaction, projectId); + const now = new Date(); + const currentProject = await findProject(projectId, transaction); + await reconcileProjectWorkGraph(transaction, currentProject, actor, now); // Load the complete project graph before ranking. Filtering out Future or // currently-owned dependency nodes here would make a dependent item look // runnable even though the dependency is still unresolved. - const rows = await transaction.workGraphNode.findMany({ where: { projectId: project.id }, include: { dependencies: true, dependents: true, evidence: true } }); - const candidates = rankReadyWork(deriveNodes(rows.map(nodeView))); + const rows = await transaction.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true, dependents: true, evidence: true } }); + const candidates = rankReadyWork(deriveNodes(rows.map(nodeView), now)); const chosen = candidates[0]; if (!chosen) return undefined; const leaseToken = randomUUID(); - const updated = await transaction.workGraphNode.updateMany({ where: { id: chosen.id, projectId: project.id, scope: { in: ["required", "committed"] }, OR: [{ executionEligibility: "ready" }, { executionEligibility: "running", leaseExpiresAt: { lt: now } }], AND: [{ OR: [{ owner: null }, { leaseExpiresAt: { lt: now } }] }] }, data: { owner: actor, leaseToken, leaseExpiresAt: new Date(now.getTime() + Math.max(5, Math.min(120, leaseMinutes)) * 60_000), executionEligibility: "running", canonicalLifecycle: "running", blockReason: null, attemptCount: { increment: 1 }, lastMeaningfulProgressAt: now } }); + const updated = await transaction.workGraphNode.updateMany({ where: { id: chosen.id, projectId, scope: { in: ["required", "committed"] }, OR: [{ executionEligibility: "ready" }, { executionEligibility: "running", leaseExpiresAt: { lt: now } }], AND: [{ OR: [{ owner: null }, { leaseExpiresAt: { lt: now } }] }] }, data: { owner: actor, leaseToken, leaseExpiresAt: new Date(now.getTime() + Math.max(5, Math.min(120, leaseMinutes)) * 60_000), executionEligibility: "running", canonicalLifecycle: "running", blockReason: null, attemptCount: { increment: 1 }, lastMeaningfulProgressAt: now } }); if (updated.count !== 1) return undefined; - await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: chosen.id, eventType: "work.claimed", actor, summary: `Claimed '${chosen.title}'.`, payloadJson: json({ leaseToken }) } }); + await transaction.workGraphAuditEvent.create({ data: { projectId, nodeId: chosen.id, eventType: "work.claimed", actor, summary: `Claimed '${chosen.title}'.`, payloadJson: json({ leaseToken }) } }); const result = await transaction.workGraphNode.findUniqueOrThrow({ where: { id: chosen.id }, include: { dependencies: true, dependents: true, evidence: true } }); return { leaseToken, node: nodeView(result) }; }); @@ -511,20 +548,21 @@ export const releaseWork = async (projectIdOrSlug: string, nodeId: string, actor }; export const reclaimExpiredWork = async (projectIdOrSlug: string, actor = "system") => { - const project = await findProject(projectIdOrSlug); - const now = new Date(); + const projectId = await resolveProjectId(projectIdOrSlug); const reclaimedCount = await prisma.$transaction(async (transaction) => { - const expired = await transaction.workGraphNode.findMany({ where: { projectId: project.id, leaseExpiresAt: { lt: now }, owner: { not: null }, executionEligibility: "running" }, select: { id: true, title: true, owner: true, leaseExpiresAt: true } }); + await lockWorkGraphProject(transaction, projectId); + const now = new Date(); + const expired = await transaction.workGraphNode.findMany({ where: { projectId, leaseExpiresAt: { lt: now }, owner: { not: null }, executionEligibility: "running" }, select: { id: true, title: true, owner: true, leaseExpiresAt: true } }); let count = 0; for (const node of expired) { - const updated = await transaction.workGraphNode.updateMany({ where: { id: node.id, projectId: project.id, executionEligibility: "running", leaseExpiresAt: { lt: now } }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "ready", canonicalLifecycle: "ready", consecutiveFailureCount: { increment: 1 }, blockReason: "Previous lease expired without a completion report." } }); + const updated = await transaction.workGraphNode.updateMany({ where: { id: node.id, projectId, executionEligibility: "running", leaseExpiresAt: { lt: now } }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "ready", canonicalLifecycle: "ready", consecutiveFailureCount: { increment: 1 }, blockReason: "Previous lease expired without a completion report." } }); if (updated.count !== 1) continue; count += 1; - await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "work.reclaimed", actor, summary: `Reclaimed stale work '${node.title}'.`, payloadJson: json({ previousOwner: node.owner, expiredAt: node.leaseExpiresAt?.toISOString() }) } }); + await transaction.workGraphAuditEvent.create({ data: { projectId, nodeId: node.id, eventType: "work.reclaimed", actor, summary: `Reclaimed stale work '${node.title}'.`, payloadJson: json({ previousOwner: node.owner, expiredAt: node.leaseExpiresAt?.toISOString() }) } }); } return count; }); - return { reclaimedCount, graph: await getProjectWorkGraph(project.id) }; + return { reclaimedCount, graph: await getProjectWorkGraph(projectId) }; }; export const recordWorkCompletion = async (projectIdOrSlug: string, nodeId: string, actor: string, leaseToken: string, input: { summary: string; evidence?: Array<{ kind: string; referenceType: string; referenceId: string; status?: string }> }) => { @@ -545,26 +583,33 @@ export const recordWorkCompletion = async (projectIdOrSlug: string, nodeId: stri }; export const updateWorkDependencies = async (projectIdOrSlug: string, nodeId: string, dependencyNodeIds: string[], actor: string) => { - const { project, node } = await assertProjectNode(projectIdOrSlug, nodeId); + const projectId = await resolveProjectId(projectIdOrSlug); const uniqueIds = Array.from(new Set(dependencyNodeIds)); - if (uniqueIds.includes(nodeId)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE"); - const allNodes = await prisma.workGraphNode.findMany({ where: { projectId: project.id }, include: { dependencies: true } }); - const edges = new Map(allNodes.map((candidate) => [candidate.id, candidate.dependencies.map((edge) => edge.dependsOnNodeId)])); - const reaches = (from: string, target: string, seen = new Set()): boolean => { - if (from === target) return true; - if (seen.has(from)) return false; - seen.add(from); - return (edges.get(from) ?? []).some((dependency) => reaches(dependency, target, seen)); - }; - for (const dependencyId of uniqueIds) { - if (!edges.has(dependencyId) || reaches(dependencyId, node.id)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE"); - } await prisma.$transaction(async (transaction) => { - await transaction.workGraphDependency.deleteMany({ where: { projectId: project.id, nodeId: node.id } }); - if (uniqueIds.length) await transaction.workGraphDependency.createMany({ data: uniqueIds.map((dependsOnNodeId) => ({ projectId: project.id, nodeId: node.id, dependsOnNodeId, createdBy: actor })) }); - await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "dependencies.updated", actor, summary: `Updated dependencies for '${node.title}'.`, payloadJson: json({ dependencyNodeIds: uniqueIds }) } }); + await lockWorkGraphProject(transaction, projectId); + const node = await transaction.workGraphNode.findFirst({ where: { id: nodeId, projectId }, select: { id: true, title: true, executionEligibility: true, owner: true, leaseExpiresAt: true } }); + if (!node) throw new Error("WORK_GRAPH_NODE_NOT_FOUND"); + if (uniqueIds.includes(nodeId)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE"); + const now = new Date(); + const hasActiveLease = node.executionEligibility === "running" + && Boolean(node.owner && node.leaseExpiresAt && node.leaseExpiresAt.getTime() > now.getTime()); + if (hasActiveLease) throw new Error("WORK_GRAPH_ACTIVE_LEASE"); + const allNodes = await transaction.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true } }); + const edges = new Map(allNodes.map((candidate) => [candidate.id, candidate.dependencies.map((edge) => edge.dependsOnNodeId)])); + const reaches = (from: string, target: string, seen = new Set()): boolean => { + if (from === target) return true; + if (seen.has(from)) return false; + seen.add(from); + return (edges.get(from) ?? []).some((dependency) => reaches(dependency, target, seen)); + }; + for (const dependencyId of uniqueIds) { + if (!edges.has(dependencyId) || reaches(dependencyId, node.id)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE"); + } + await transaction.workGraphDependency.deleteMany({ where: { projectId, nodeId: node.id } }); + if (uniqueIds.length) await transaction.workGraphDependency.createMany({ data: uniqueIds.map((dependsOnNodeId) => ({ projectId, nodeId: node.id, dependsOnNodeId, createdBy: actor })) }); + await transaction.workGraphAuditEvent.create({ data: { projectId, nodeId: node.id, eventType: "dependencies.updated", actor, summary: `Updated dependencies for '${node.title}'.`, payloadJson: json({ dependencyNodeIds: uniqueIds }) } }); }); - return getProjectWorkGraph(project.id); + return getProjectWorkGraph(projectId); }; export const listWorkGraphHistory = async (projectIdOrSlug: string, limit = 100) => { diff --git a/backend/test/activity-runtime.test.ts b/backend/test/activity-runtime.test.ts index 5f01199..6dfa5b3 100644 --- a/backend/test/activity-runtime.test.ts +++ b/backend/test/activity-runtime.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { loadEnv } from "../src/config/env.js"; import type { GiteaActionJob } from "../src/integrations/gitea/types.js"; import { compactGiteaRuntime, createGiteaActivityRuntimeLoader, repositoryFromActionJob } from "../src/services/activityRuntime.js"; +import { evaluateRunnerClaimHealth } from "../src/services/runnerClaimHealth.js"; const env = loadEnv({ NODE_ENV: "test", @@ -44,6 +45,26 @@ describe("compact activity runtime", () => { expect(snapshot.counts).toEqual({ running: 2, runnable: 30, dependencyBlocked: 0 }); expect(snapshot.jobs).toHaveLength(32); }); + it("shows queued jobs oldest first so the visible lane reflects waiting age", () => { + const snapshot = compactGiteaRuntime([], [ + job({ id: 9, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:09:00Z" }), + job({ id: 7, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:03:00Z" }), + job({ id: 8, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:06:00Z" }), + ], "2026-08-28T19:10:00Z"); + expect(snapshot.jobs.map((item) => item.id)).toEqual([7, 8, 9]); + }); + + it("puts unknown queued timestamps last and breaks equal creation times by ascending job id", () => { + const snapshot = compactGiteaRuntime([], [ + job({ id: 9, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:03:00Z" }), + job({ id: 4, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:03:00Z" }), + job({ id: 7, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:06:00Z" }), + job({ id: 2, status: "queued", runner_id: 0, runner_name: undefined, created_at: "not-a-timestamp" }), + ], "2026-08-28T19:10:00Z"); + + expect(snapshot.jobs.map((item) => item.id)).toEqual([4, 9, 7, 2]); + }); + it("extracts a repository safely from a Gitea job URL", () => { expect(repositoryFromActionJob(job({ html_url: "https://gitea.example.com/Space%20Owner/Repo/actions/runs/1/jobs/2" }))).toBe("Space Owner/Repo"); expect(repositoryFromActionJob(job({ html_url: "not-a-url" }))).toBeUndefined(); @@ -123,6 +144,49 @@ describe("compact activity runtime", () => { expect(snapshot.jobs[0].queueState).toBe("dependency-blocked"); }); + it("keeps runner health unknown when runner discovery fails but queued jobs remain visible", async () => { + const queuedJob = job({ status: "queued", runner_id: 0, runner_name: undefined }); + const listUserActionRunners = vi.fn().mockRejectedValue(new Error("runner inventory unavailable")); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued" + ? { total_count: 1, jobs: [queuedJob] } + : { total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } }); + + const snapshot = await load(); + const health = evaluateRunnerClaimHealth(snapshot, { now: Date.parse("2026-09-30T00:05:00.000Z") }); + + expect(snapshot).toMatchObject({ + availability: "live", + runnerInventoryComplete: false, + warning: "Partial Gitea monitoring failure: runner inventory unavailable", + runnableJobScan: { complete: true, observed: 1, total: 1 }, + }); + expect(health).toMatchObject({ + status: "incomplete", + runnerInventoryComplete: false, + coverage: { complete: true, observed: 1, total: 1 }, + counts: { queued: 1, unknown: 1, labelBlocked: 0, pollingStalled: 0 }, + jobs: [{ state: "unknown", claimableWaitSeconds: 0, requiredLabels: ["ubuntu-latest"], compatibleRunnerNames: [], compatibleIdleRunnerNames: [] }], + }); + + const emptyInventory = createGiteaActivityRuntimeLoader(env, { + client: { + listUserActionRunners: vi.fn().mockResolvedValue({ total_count: 0, runners: [] }), + listUserActionJobs, + }, + }); + const knownEmptySnapshot = await emptyInventory(); + const knownEmptyHealth = evaluateRunnerClaimHealth(knownEmptySnapshot, { now: Date.parse("2026-09-30T00:05:00.000Z") }); + + expect(knownEmptySnapshot.runnerInventoryComplete).toBe(true); + expect(knownEmptyHealth).toMatchObject({ + status: "degraded", + runnerInventoryComplete: true, + counts: { queued: 1, unknown: 0, labelBlocked: 1 }, + jobs: [{ state: "label-blocked" }], + }); + }); + it("uses Gitea's documented pending filter for dependency-blocked jobs", async () => { const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] }); const listUserActionJobs = vi.fn().mockResolvedValue({ total_count: 0, jobs: [] }); @@ -133,4 +197,361 @@ describe("compact activity runtime", () => { expect(listUserActionJobs).toHaveBeenCalledWith({ status: "pending", limit: 50 }); expect(listUserActionJobs).not.toHaveBeenCalledWith({ status: "waiting", limit: 50 }); }); + + it("keeps Gitea requested jobs returned by the pending filter visible without a claim-health alarm", async () => { + const requestedJob = job({ + id: 37_222, + run_id: 21_559, + name: "quality", + status: "requested", + runner_id: 0, + runner_name: undefined, + steps: [], + created_at: "2026-10-03T02:20:00.000Z", + }); + const recentCompleted = Array.from({ length: 30 }, (_, index) => job({ + id: 40_000 + index, + run_id: 30_000 + index, + status: "completed", + conclusion: "success", + runner_id: 0, + runner_name: undefined, + created_at: "2026-10-03T02:21:00.000Z", + completed_at: "2026-10-03T02:22:00.000Z", + })); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => { + if (input.status === "pending") return { total_count: 1, jobs: [requestedJob] }; + if (input.status === "queued") return { total_count: 0, jobs: [] }; + if (input.status === "in_progress") return { total_count: 0, jobs: [] }; + return { total_count: recentCompleted.length, jobs: recentCompleted }; + }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } }); + + const snapshot = await load(); + const requested = snapshot.jobs.find((entry) => entry.id === requestedJob.id); + const health = evaluateRunnerClaimHealth(snapshot, { now: Date.parse("2026-10-03T02:24:00.000Z") }); + + expect(listUserActionJobs).toHaveBeenCalledWith({ status: "pending", limit: 50 }); + expect(requested).toMatchObject({ + id: 37_222, + runId: 21_559, + status: "requested", + queueState: "dependency-blocked", + }); + expect(snapshot.counts).toEqual({ running: 0, runnable: 0, dependencyBlocked: 1 }); + expect(health).toMatchObject({ + status: "healthy", + counts: { queued: 0, pollingStalled: 0 }, + oldestWaitSeconds: 0, + }); + }); + + it("paginates the queued-job health scan so old jobs beyond page one are diagnosed", async () => { + let clock = new Date("2026-09-30T00:05:00.000Z"); + const queuedJobs = [ + ...Array.from({ length: 50 }, (_, index) => job({ + id: 1_000 - index, + status: "queued", + runner_id: 0, + runner_name: undefined, + created_at: "2026-09-30T00:04:30.000Z", + })), + ...Array.from({ length: 10 }, (_, index) => job({ + id: 900 - index, + status: "queued", + runner_id: 0, + runner_name: undefined, + created_at: "2026-09-30T00:00:00.000Z", + })), + ]; + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => { + if (input.status === "queued") { + const start = ((input.page ?? 1) - 1) * (input.limit ?? 50); + return { total_count: queuedJobs.length, jobs: queuedJobs.slice(start, start + (input.limit ?? 50)) }; + } + return { total_count: 0, jobs: [] }; + }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + let snapshot = await load(); + for (let sample = 0; sample < 4; sample += 1) { + clock = new Date(clock.getTime() + 30_000); + snapshot = await load(); + } + const health = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }); + + expect(listUserActionJobs).toHaveBeenCalledWith({ status: "queued", limit: 50, page: 1 }); + expect(listUserActionJobs).toHaveBeenCalledWith({ status: "queued", limit: 50, page: 2 }); + expect(snapshot.runnableJobScan).toEqual({ complete: true, observed: 60, total: 60 }); + expect(health).toMatchObject({ + status: "stalled", + coverage: { complete: true, observed: 60, total: 60 }, + counts: { queued: 60, pollingStalled: 60 }, + oldestWaitSeconds: 120, + }); + }); + + it("does not treat an old database-created job as a long-waiting queued job when first observed", async () => { + const clock = new Date("2026-09-30T00:05:00.000Z"); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued" + ? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" })] } + : { total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + const snapshot = await load(); + const health = evaluateRunnerClaimHealth(snapshot, { now: Date.parse("2026-09-30T04:00:00.000Z"), stallSeconds: 120 }); + + expect(snapshot.jobs[0]).toMatchObject({ + createdAt: "2026-09-29T00:00:00.000Z", + runnableObservedSince: clock.toISOString(), + claimableObservedSince: clock.toISOString(), + }); + expect(health).toMatchObject({ + status: "healthy", + counts: { queued: 1, claimable: 1, pollingStalled: 0 }, + oldestWaitSeconds: 0, + }); + }); + + it("starts polling-stall timing only after compatible idle capacity is observed", async () => { + let clock = new Date("2026-09-30T00:00:00.000Z"); + let runnerBusy = true; + const listUserActionRunners = vi.fn().mockImplementation(async () => ({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: runnerBusy, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] })); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued" + ? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" })] } + : { total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + let snapshot = await load(); + expect(evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }).jobs[0].state).toBe("capacity-wait"); + for (let sample = 0; sample < 3; sample += 1) { + clock = new Date(clock.getTime() + 30_000); + await load(); + } + + runnerBusy = false; + clock = new Date(clock.getTime() + 30_000); + snapshot = await load(); + expect(snapshot.jobs[0]).toMatchObject({ + runnableObservedSince: "2026-09-30T00:00:00.000Z", + claimableObservedSince: clock.toISOString(), + }); + expect(evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }).jobs[0].state).toBe("claimable"); + + for (let sample = 0; sample < 3; sample += 1) { + clock = new Date(clock.getTime() + 30_000); + snapshot = await load(); + expect(evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }).jobs[0].state).toBe("claimable"); + } + clock = new Date(clock.getTime() + 30_000); + snapshot = await load(); + const finalHealth = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }); + expect(finalHealth.jobs[0]).toMatchObject({ state: "polling-stalled", waitSeconds: 240, claimableWaitSeconds: 120 }); + expect(finalHealth.oldestClaimableWaitSeconds).toBe(120); + }); + + it("does not age a cached health sample with wall-clock time", async () => { + let clock = new Date("2026-09-30T00:00:00.000Z"); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued" + ? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined })] } + : { total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + await load(); + clock = new Date(clock.getTime() + 14_000); + const cached = await load(); + const health = evaluateRunnerClaimHealth(cached, { now: clock.getTime() + 3_600_000, stallSeconds: 30 }); + + expect(listUserActionRunners).toHaveBeenCalledTimes(1); + expect(health).toMatchObject({ status: "healthy", oldestWaitSeconds: 0, counts: { pollingStalled: 0 } }); + }); + + it("resets observation timing after a long gap between complete samples", async () => { + let clock = new Date("2026-09-30T00:00:00.000Z"); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued" + ? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" })] } + : { total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + await load(); + clock = new Date(clock.getTime() + 46_000); + const snapshot = await load(); + const health = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 30 }); + + expect(snapshot.jobs[0].runnableObservedSince).toBe(clock.toISOString()); + expect(snapshot.jobs[0].claimableObservedSince).toBe(clock.toISOString()); + expect(health).toMatchObject({ status: "healthy", oldestWaitSeconds: 0, counts: { pollingStalled: 0 } }); + }); + + it("resets claimable timing after partial and stale samples", async () => { + let clock = new Date("2026-09-30T00:00:00.000Z"); + let failSecondPage = false; + let failAllJobQueries = false; + const queuedJobs = Array.from({ length: 51 }, (_, index) => job({ + id: 1_000 - index, + status: "queued", + runner_id: 0, + runner_name: undefined, + created_at: "2026-09-29T00:00:00.000Z", + })); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => { + if (failAllJobQueries) throw new Error("job queries unavailable"); + if (input.status === "queued") { + if (input.page === 2 && failSecondPage) throw new Error("queued page unavailable"); + const start = ((input.page ?? 1) - 1) * (input.limit ?? 50); + return { total_count: queuedJobs.length, jobs: queuedJobs.slice(start, start + (input.limit ?? 50)) }; + } + return { total_count: 0, jobs: [] }; + }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + await load(); + failSecondPage = true; + clock = new Date(clock.getTime() + 30_000); + const partial = await load(); + expect(partial.runnableJobScan.complete).toBe(false); + expect(evaluateRunnerClaimHealth(partial, { now: clock.getTime() }).status).toBe("incomplete"); + + failSecondPage = false; + clock = new Date(clock.getTime() + 15_000); + let snapshot = await load(); + expect(snapshot.jobs.find((entry) => entry.id === queuedJobs[0].id)?.claimableObservedSince).toBe(clock.toISOString()); + + failAllJobQueries = true; + clock = new Date(clock.getTime() + 15_000); + const stale = await load(); + expect(stale.availability).toBe("stale"); + expect(evaluateRunnerClaimHealth(stale, { now: clock.getTime() }).jobs[0].state).toBe("unknown"); + + failAllJobQueries = false; + clock = new Date(clock.getTime() + 30_000); + snapshot = await load(); + const health = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 30 }); + expect(snapshot.jobs.find((entry) => entry.id === queuedJobs[0].id)?.claimableObservedSince).toBe(clock.toISOString()); + expect(health).toMatchObject({ status: "healthy", counts: { pollingStalled: 0 }, oldestWaitSeconds: 0 }); + }); + + it("restarts runnable timing after a known queued-to-pending-to-queued transition", async () => { + let clock = new Date("2026-09-30T00:00:00.000Z"); + let queued = true; + const queuedJob = job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" }); + const pendingJob = { ...queuedJob, status: "pending" }; + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [ + { id: 1, name: "ubuntu-latest", type: "custom" }, + ] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => { + if (input.status === "queued") return { total_count: queued ? 1 : 0, jobs: queued ? [queuedJob] : [] }; + if (input.status === "pending" || input.status === undefined) return { total_count: queued ? 0 : 1, jobs: queued ? [] : [pendingJob] }; + return { total_count: 0, jobs: [] }; + }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + const first = await load(); + expect(first.jobs[0].runnableObservedSince).toBe(clock.toISOString()); + + queued = false; + clock = new Date(clock.getTime() + 30_000); + const pending = await load(); + expect(pending.jobs[0].queueState).toBe("dependency-blocked"); + + queued = true; + clock = new Date(clock.getTime() + 30_000); + const requeued = await load(); + expect(requeued.jobs[0].runnableObservedSince).toBe(clock.toISOString()); + expect(requeued.jobs[0].claimableObservedSince).toBe(clock.toISOString()); + expect(evaluateRunnerClaimHealth(requeued, { now: clock.getTime(), stallSeconds: 30 }).jobs[0].state).toBe("claimable"); + }); + + it("keeps paginated health scans bounded and marks queues beyond the cap incomplete", async () => { + const queuedJobs = Array.from({ length: 501 }, (_, index) => job({ + id: 2_000 - index, + status: "queued", + runner_id: 0, + runner_name: undefined, + })); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => { + if (input.status === "queued") { + const start = ((input.page ?? 1) - 1) * (input.limit ?? 50); + return { total_count: queuedJobs.length, jobs: queuedJobs.slice(start, start + (input.limit ?? 50)) }; + } + return { total_count: 0, jobs: [] }; + }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } }); + + const snapshot = await load(); + + expect(listUserActionJobs.mock.calls.filter(([input]) => input?.status === "queued")).toHaveLength(10); + expect(snapshot.runnableJobScan).toEqual({ complete: false, observed: 500, total: 501 }); + expect(evaluateRunnerClaimHealth(snapshot).status).toBe("incomplete"); + }); + + it("does not treat a missing queued total as an empty complete scan", async () => { + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued" + ? { jobs: [] } as unknown as { total_count: number; jobs: GiteaActionJob[] } + : { total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } }); + + const snapshot = await load(); + + expect(snapshot.runnableJobScan).toEqual({ complete: false, observed: 0, total: null }); + expect(snapshot.warning).toContain("invalid queued-job total"); + expect(evaluateRunnerClaimHealth(snapshot).status).toBe("incomplete"); + }); + + it("marks the queued health scan incomplete when a later page fails", async () => { + const queuedJobs = Array.from({ length: 60 }, (_, index) => job({ id: 1_000 - index, status: "queued" })); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => { + if (input.status === "queued") { + if (input.page === 2) throw new Error("page two unavailable"); + return { total_count: queuedJobs.length, jobs: queuedJobs.slice(0, input.limit ?? 50) }; + } + return { total_count: 0, jobs: [] }; + }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } }); + + const snapshot = await load(); + + expect(snapshot.runnableJobScan).toEqual({ complete: false, observed: 50, total: 60 }); + expect(snapshot.warning).toContain("page two unavailable"); + expect(evaluateRunnerClaimHealth(snapshot).status).toBe("incomplete"); + }); }); diff --git a/backend/test/autonomy.test.ts b/backend/test/autonomy.test.ts index 5472d97..109b8f3 100644 --- a/backend/test/autonomy.test.ts +++ b/backend/test/autonomy.test.ts @@ -3,7 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadEnv } from "../src/config/env.js"; import { compareAutoModeProjectRows, createProjectBrainMcpServer } from "../src/mcp/autonomyServer.js"; -import { autonomyRetryDelayMinutes, canResumeAutonomyLease, classifyAutonomyBlocker, enqueueAutonomyTaskInState, evaluateAutonomyRunBudget, isAutonomyLeaseOwner, normalizeAutonomyState, parseRoadmapMarkdown, releaseActiveAutonomyLease, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js"; +import { autonomyRetryDelayMinutes, canResumeAutonomyLease, classifyAutonomyBlocker, enqueueAutonomyTaskInState, evaluateAutonomyRunBudget, isAutonomyLeaseOwner, normalizeAutonomyState, parseRoadmapMarkdown, releaseActiveAutonomyLease, reorderAutonomyQueueInState, resetAutonomyQueueOrderInState, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js"; import { autonomyHeartbeatSlotId, compactAutonomyState, resolveAutonomyHeartbeat, summarizeAutonomyProject } from "../src/services/autonomyRuntime.js"; const env = loadEnv({ @@ -99,6 +99,25 @@ describe("Auto Mode state safety", () => { expect(queued.state.roadmap?.phases[1].items[1].title).toBe("Lopend werk"); }); + it("persists an exact owner-defined order for pending executable work only", () => { + const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] A\n- [ ] B\n- [ ] C"); + roadmap.phases[0].items[2].scope = "future"; + const current = normalizeAutonomyState({ enabled: true, roadmap }, "owner"); + const [a, b] = current.roadmap!.phases[0].items; + + const reordered = reorderAutonomyQueueInState(current, [b.id, a.id], "owner"); + + expect(reordered.roadmap?.phases[0].items[0].manualOrder).toBe(1); + expect(reordered.roadmap?.phases[0].items[1].manualOrder).toBe(0); + expect(reordered.roadmap?.phases[0].items[2].manualOrder).toBeUndefined(); + expect(reordered.events.at(-1)?.type).toBe("autonomy.queue.reordered"); + expect(() => reorderAutonomyQueueInState(current, [a.id], "owner")).toThrow("AUTO_MODE_QUEUE_CHANGED"); + + const automatic = resetAutonomyQueueOrderInState(reordered, "owner"); + expect(automatic.roadmap?.phases.flatMap((phase) => phase.items).every((item) => item.manualOrder === undefined)).toBe(true); + expect(automatic.events.at(-1)?.type).toBe("autonomy.queue.order_reset"); + }); + it("derives the public status from durable facts instead of stale stored copy", () => { expect(normalizeAutonomyState({ enabled: true, status: "running" }).status).toBe("waiting_roadmap"); expect(normalizeAutonomyState({}).profile).toBe("normal"); @@ -305,8 +324,11 @@ describe("Auto Mode MCP surface", () => { "list_auto_mode_projects", "heartbeat_auto_executor", "get_auto_mode_state", + "get_runner_claim_health", "configure_auto_mode", "import_auto_roadmap", + "reorder_auto_queue", + "reset_auto_queue_order", "resume_auto_mode", "claim_auto_work", "report_auto_work", @@ -314,6 +336,9 @@ describe("Auto Mode MCP surface", () => { expect(advertisedTools.find((tool) => tool.name === "claim_auto_work")?.inputSchema).toMatchObject({ required: expect.arrayContaining(["project_id", "run_id"]), }); + expect(advertisedTools.find((tool) => tool.name === "reorder_auto_queue")?.inputSchema).toMatchObject({ + required: expect.arrayContaining(["project_id", "item_ids"]), + }); expect(advertisedTools.find((tool) => tool.name === "configure_auto_mode")?.inputSchema).toMatchObject({ properties: expect.objectContaining({ max_work_items_per_run: expect.any(Object), max_run_minutes: expect.any(Object), max_recovery_minutes: expect.any(Object), pause_after_current: expect.any(Object), scheduling_policy: expect.any(Object) }), }); diff --git a/backend/test/autonomyScheduler.test.ts b/backend/test/autonomyScheduler.test.ts index 54465ab..9ac0792 100644 --- a/backend/test/autonomyScheduler.test.ts +++ b/backend/test/autonomyScheduler.test.ts @@ -36,6 +36,27 @@ describe("smart autonomy scheduler", () => { expect(chooseNextAutonomyWork(roadmap.phases, 0, "downstream-impact")?.item.id).toBe(a.id); }); + it("honors explicit manual queue order before automatic scoring", () => { + const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] High score\n- [ ] Owner first"); + const [highScore, ownerFirst] = roadmap.phases[0].items; + highScore.priority = 100; + ownerFirst.priority = 0; + highScore.manualOrder = 1; + ownerFirst.manualOrder = 0; + + expect(chooseNextAutonomyWork(roadmap.phases, 0)?.item.id).toBe(ownerFirst.id); + }); + + it("never lets manual ordering bypass an unmet dependency", () => { + const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] Prerequisite\n- [ ] Manually first"); + const [prerequisite, manuallyFirst] = roadmap.phases[0].items; + manuallyFirst.dependsOn = [prerequisite.id]; + manuallyFirst.manualOrder = 0; + prerequisite.manualOrder = 1; + + expect(chooseNextAutonomyWork(roadmap.phases, 0)?.item.id).toBe(prerequisite.id); + }); + it("does not claim work while its external dependency is unavailable", () => { const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B"); roadmap.phases[0].items[0].waitingExternal = true; diff --git a/backend/test/database-autonomy.test.ts b/backend/test/database-autonomy.test.ts index 515a987..4a31f19 100644 --- a/backend/test/database-autonomy.test.ts +++ b/backend/test/database-autonomy.test.ts @@ -1,4 +1,6 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { Prisma } from "@prisma/client"; import { prisma } from "../src/db/prisma.js"; import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork } from "../src/repositories/autonomyRepository.js"; import { claimAutonomyWork } from "../src/services/autonomyRuntimeControl.js"; @@ -11,12 +13,204 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createProjectBrainMcpServer } from "../src/mcp/server.js"; import { loadEnv } from "../src/config/env.js"; import { GiteaApiError, GiteaClient } from "../src/integrations/gitea/client.js"; +import { claimReadyWork, getProjectWorkGraph, reclaimExpiredWork, recordWorkCompletion, syncProjectWorkGraph, updateWorkDependencies } from "../src/services/workGraph.js"; const databaseUrl = process.env.TEST_DATABASE_URL; if (databaseUrl) process.env.DATABASE_URL = databaseUrl; const databaseDescribe = databaseUrl ? describe : describe.skip; const slugs: string[] = []; +const createWorkGraphProject = async (markdown: string) => { + const slug = `workgraph-${randomUUID()}`; + slugs.push(slug); + await createProject({ id: slug, name: "Work graph concurrency proof", description: "Isolated database regression" }); + await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test"); + await importAutonomyRoadmap(slug, { markdown }, "database-test"); + const project = await prisma.project.findUniqueOrThrow({ where: { slug } }); + const graph = await getProjectWorkGraph(slug); + return { slug, project, graph }; +}; + +const nodeByTitle = (graph: Awaited>, title: string) => { + const node = graph.nodes.find((candidate) => candidate.title === title); + if (!node) throw new Error(`Missing work graph fixture node '${title}'.`); + return node; +}; + +const setAutonomyDependencies = async (projectId: string, slug: string, title: string, dependsOnTitles: string[]) => { + const state = await getAutonomyState(slug); + const items = state.roadmap?.phases.flatMap((phase) => phase.items) ?? []; + const target = items.find((item) => item.title === title); + if (!target) throw new Error(`Missing autonomy fixture item '${title}'.`); + target.dependsOn = dependsOnTitles.map((dependencyTitle) => { + const dependency = items.find((item) => item.title === dependencyTitle); + if (!dependency) throw new Error(`Missing autonomy fixture dependency '${dependencyTitle}'.`); + return dependency.id; + }); + const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId }, select: { notesJson: true } }); + const notes = typeof workspace.notesJson === "object" && workspace.notesJson !== null && !Array.isArray(workspace.notesJson) ? workspace.notesJson : {}; + await prisma.workspaceState.update({ where: { projectId }, data: { notesJson: { ...notes, autonomy: state } as Prisma.InputJsonValue } }); +}; + +const setAutonomyPriority = async (projectId: string, slug: string, title: string, priority: number) => { + const state = await getAutonomyState(slug); + const target = state.roadmap?.phases.flatMap((phase) => phase.items).find((item) => item.title === title); + if (!target) throw new Error(`Missing autonomy fixture item '${title}'.`); + target.priority = priority; + const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId }, select: { notesJson: true } }); + const notes = typeof workspace.notesJson === "object" && workspace.notesJson !== null && !Array.isArray(workspace.notesJson) ? workspace.notesJson : {}; + await prisma.workspaceState.update({ where: { projectId }, data: { notesJson: { ...notes, autonomy: state } as Prisma.InputJsonValue } }); +}; + +const setAutonomyRoadmapOrder = async (projectId: string, slug: string, titles: string[]) => { + const state = await getAutonomyState(slug); + const itemOrder = new Map(titles.map((title, index) => [title, index])); + const items = state.roadmap?.phases.flatMap((phase) => phase.items) ?? []; + if (items.length !== titles.length || titles.some((title) => !items.some((item) => item.title === title))) { + throw new Error("The requested roadmap order does not match the source fixture."); + } + for (const phase of state.roadmap?.phases ?? []) { + phase.items.sort((left, right) => itemOrder.get(left.title)! - itemOrder.get(right.title)!); + } + const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId }, select: { notesJson: true } }); + const notes = typeof workspace.notesJson === "object" && workspace.notesJson !== null && !Array.isArray(workspace.notesJson) ? workspace.notesJson : {}; + await prisma.workspaceState.update({ where: { projectId }, data: { notesJson: { ...notes, autonomy: state } as Prisma.InputJsonValue } }); +}; + +type DatabaseLockWaiter = { pid: number; query: string; blockingPids: number[]; hasProjectRowShareLock: boolean }; +const readDatabaseLockWaiters = () => prisma.$queryRaw` + SELECT activity.pid, activity.query, pg_blocking_pids(activity.pid) AS "blockingPids", + EXISTS ( + SELECT 1 FROM pg_locks + WHERE pg_locks.pid = activity.pid + AND pg_locks.relation = '"Project"'::regclass + AND pg_locks.mode = 'RowShareLock' + AND pg_locks.granted + ) AS "hasProjectRowShareLock" + FROM pg_stat_activity AS activity + WHERE activity.datname = current_database() + AND activity.pid <> pg_backend_pid() + AND activity.state = 'active' + AND activity.wait_event_type = 'Lock' +`; + +const isProjectCoordinationLockQuery = (query: string) => { + const normalized = query.toLowerCase(); + return normalized.includes("for update") || normalized.includes("for no key update"); +}; + +const waitForDatabaseLockWaiter = async (predicate: (waiter: DatabaseLockWaiter) => boolean, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const waiter = (await readDatabaseLockWaiters()).find(predicate); + if (waiter) return waiter; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return undefined; +}; + +const createAuditInsertBarrier = async (projectId: string, eventType: "work.completed" | "work.reclaimed") => { + const suffix = randomUUID().replaceAll("-", ""); + const triggerName = `test_audit_${suffix}`; + const functionName = `${triggerName}_fn`; + const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const; + let blockerPid = 0; + let releaseAdvisoryLock: () => void = () => undefined; + let acquiredAdvisoryLock!: () => void; + let functionCreated = false; + let triggerCreated = false; + let lockHolder: Promise | undefined; + const locked = new Promise((resolve) => { acquiredAdvisoryLock = resolve; }); + const hold = new Promise((resolve) => { releaseAdvisoryLock = resolve; }); + const cleanup = async () => { + releaseAdvisoryLock(); + if (lockHolder) await lockHolder.catch(() => undefined); + if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphAuditEvent"`); + if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`); + }; + try { + await prisma.$executeRawUnsafe(` + CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]}); + RETURN NEW; + END; + $$ + `); + functionCreated = true; + await prisma.$executeRawUnsafe(` + CREATE TRIGGER ${triggerName} + BEFORE INSERT ON "WorkGraphAuditEvent" + FOR EACH ROW WHEN (NEW."projectId" = '${projectId}' AND NEW."eventType" = '${eventType}') + EXECUTE FUNCTION ${functionName}(); + `); + triggerCreated = true; + lockHolder = prisma.$transaction(async (transaction) => { + const [backend] = await transaction.$queryRaw>`SELECT pg_backend_pid() AS pid`; + blockerPid = backend!.pid; + await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`; + acquiredAdvisoryLock(); + await hold; + }); + await Promise.race([locked, lockHolder.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]); + return { blockerPid, release: releaseAdvisoryLock, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +}; + +const createWorkGraphNodeUpdateBarrier = async (nodeId: string) => { + const suffix = randomUUID().replaceAll("-", ""); + const triggerName = `test_node_${suffix}`; + const functionName = `${triggerName}_fn`; + const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const; + let blockerPid = 0; + let releaseAdvisoryLock: () => void = () => undefined; + let acquiredAdvisoryLock!: () => void; + let functionCreated = false; + let triggerCreated = false; + let lockHolder: Promise | undefined; + const locked = new Promise((resolve) => { acquiredAdvisoryLock = resolve; }); + const hold = new Promise((resolve) => { releaseAdvisoryLock = resolve; }); + const cleanup = async () => { + releaseAdvisoryLock(); + if (lockHolder) await lockHolder.catch(() => undefined); + if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphNode"`); + if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`); + }; + try { + await prisma.$executeRawUnsafe(` + CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]}); + RETURN NEW; + END; + $$ + `); + functionCreated = true; + await prisma.$executeRawUnsafe(` + CREATE TRIGGER ${triggerName} + BEFORE UPDATE ON "WorkGraphNode" + FOR EACH ROW WHEN (NEW."id" = '${nodeId}') + EXECUTE FUNCTION ${functionName}(); + `); + triggerCreated = true; + lockHolder = prisma.$transaction(async (transaction) => { + const [backend] = await transaction.$queryRaw>`SELECT pg_backend_pid() AS pid`; + blockerPid = backend!.pid; + await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`; + acquiredAdvisoryLock(); + await hold; + }); + await Promise.race([locked, lockHolder.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]); + return { blockerPid, release: releaseAdvisoryLock, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +}; + databaseDescribe("autonomy database write isolation", () => { it("preserves progress and the active lease when the same roadmap is imported again", async () => { const slug = `autonomy-import-${Date.now()}`; @@ -107,7 +301,10 @@ databaseDescribe("autonomy database write isolation", () => { vi.spyOn(GiteaClient.prototype, "getBranch").mockResolvedValue({ name: "chatgpt/existing", commit: { id: head, message: "Repair" }, protected: false }); vi.spyOn(GiteaClient.prototype, "getContents").mockResolvedValue({ type: "file", path: "src/feature.ts", name: "feature.ts", sha: "file-sha", size: 4, encoding: "base64", content: "dGVzdA==" }); const write = vi.spyOn(GiteaClient.prototype, "updateFile").mockResolvedValue({}); - vi.spyOn(GiteaClient.prototype, "listActionRuns").mockResolvedValue({ total_count: 1, workflow_runs: [{ id: 2, run_number: 2, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" }] }); + vi.spyOn(GiteaClient.prototype, "listActionRuns").mockResolvedValue({ total_count: 2, workflow_runs: [ + { id: 2, run_number: 2, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" }, + { id: 3, run_number: 3, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/3" }, + ] }); vi.spyOn(GiteaClient.prototype, "getBranchProtection").mockResolvedValue({ enable_status_check: true, status_check_contexts: ["CI / test"] }); vi.spyOn(GiteaClient.prototype, "getCombinedCommitStatus").mockResolvedValue({ state: "success", statuses: [{ context: "CI / test", status: "success" }] }); const merge = vi.spyOn(GiteaClient.prototype, "mergePullRequest").mockImplementation(async () => { remoteMerged = true; }); @@ -162,4 +359,379 @@ databaseDescribe("autonomy database write isolation", () => { expect(integrated.spec.validationEvidence).toBeUndefined(); expect(compare).toHaveBeenLastCalledWith("AutonomyProof", slug, "b".repeat(40), "a".repeat(40)); }); + + it("serializes concurrent dependency edits before checking for cycles", async () => { + const { slug, project, graph } = await createWorkGraphProject("## Work\n- Task A\n- Task B"); + const taskA = nodeByTitle(graph, "Task A"); + const taskB = nodeByTitle(graph, "Task B"); + const suffix = randomUUID().replaceAll("-", ""); + const triggerName = `test_cycle_${suffix}`; + const functionName = `${triggerName}_fn`; + const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const; + let blockerPid = 0; + let releaseAdvisoryLock: () => void = () => undefined; + let acquiredAdvisoryLock!: () => void; + let functionCreated = false; + let triggerCreated = false; + const locked = new Promise((resolve) => { acquiredAdvisoryLock = resolve; }); + const hold = new Promise((resolve) => { releaseAdvisoryLock = resolve; }); + let lockHolder: Promise | undefined; + let edits: Array>>> | undefined; + try { + await prisma.$executeRawUnsafe(` + CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]}); + RETURN NEW; + END; + $$ + `); + functionCreated = true; + await prisma.$executeRawUnsafe(` + CREATE TRIGGER ${triggerName} + BEFORE INSERT ON "WorkGraphDependency" + FOR EACH ROW WHEN (NEW."projectId" = '${project.id}') + EXECUTE FUNCTION ${functionName}(); + `); + triggerCreated = true; + lockHolder = prisma.$transaction(async (transaction) => { + const [backend] = await transaction.$queryRaw>`SELECT pg_backend_pid() AS pid`; + blockerPid = backend!.pid; + await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`; + acquiredAdvisoryLock(); + await hold; + }); + await Promise.race([locked, lockHolder.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]); + edits = [ + updateWorkDependencies(slug, taskA.id, [taskB.id], "database-test"), + updateWorkDependencies(slug, taskB.id, [taskA.id], "database-test"), + ]; + const waitDeadline = Date.now() + 2000; + let bothAtSerializedBoundary = false; + while (!bothAtSerializedBoundary && Date.now() < waitDeadline) { + const waiters = await readDatabaseLockWaiters(); + const blockedInserts = waiters.filter((row) => row.query.toLowerCase().includes("workgraphdependency") && row.blockingPids.includes(blockerPid)); + const projectLockWaiters = waiters.filter((row) => isProjectCoordinationLockQuery(row.query)); + const baselinePair = blockedInserts.length >= 2; + const serializedPair = blockedInserts.length === 1 && projectLockWaiters.some((row) => row.blockingPids.includes(blockedInserts[0]!.pid)); + bothAtSerializedBoundary = baselinePair || serializedPair; + if (!bothAtSerializedBoundary) await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(bothAtSerializedBoundary).toBe(true); + releaseAdvisoryLock(); + const results = await Promise.allSettled(edits); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.reason).toMatchObject({ message: "WORK_GRAPH_DEPENDENCY_CYCLE" }); + const finalGraph = await getProjectWorkGraph(slug); + const finalA = nodeByTitle(finalGraph, "Task A"); + const finalB = nodeByTitle(finalGraph, "Task B"); + expect(finalA.dependencies.some((edge) => edge.dependsOnNodeId === finalB.id)).not.toBe(finalB.dependencies.some((edge) => edge.dependsOnNodeId === finalA.id)); + } finally { + releaseAdvisoryLock(); + if (lockHolder) await lockHolder.catch(() => undefined); + if (edits) await Promise.allSettled(edits); + if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphDependency"`); + if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`); + } + }, 15_000); + + it("does not attach a new unresolved prerequisite to work with an active lease", async () => { + const { slug, project } = await createWorkGraphProject("## Work\n- Runnable task\n- Prerequisite task"); + await setAutonomyPriority(project.id, slug, "Runnable task", 100); + const currentGraph = await getProjectWorkGraph(slug); + const task = nodeByTitle(currentGraph, "Runnable task"); + const prerequisite = nodeByTitle(currentGraph, "Prerequisite task"); + const beforeDependencies = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } }); + expect(beforeDependencies).toHaveLength(0); + const claim = await claimReadyWork(slug, "claimant"); + expect(claim?.node.id).toBe(task.id); + expect(claim?.node.dependencies.map((edge) => edge.dependsOnNodeId)).toEqual(beforeDependencies.map((edge) => edge.dependsOnNodeId)); + await expect(updateWorkDependencies(slug, task.id, [prerequisite.id], "database-test")).rejects.toThrow("WORK_GRAPH_ACTIVE_LEASE"); + const persisted = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } }); + expect(persisted).toEqual(beforeDependencies); + }); + + it("serializes a dependency edit behind an in-flight claim", async () => { + const { slug, project } = await createWorkGraphProject("## Work\n- Runnable task\n- Prerequisite task"); + await setAutonomyPriority(project.id, slug, "Runnable task", 100); + const currentGraph = await getProjectWorkGraph(slug); + const task = nodeByTitle(currentGraph, "Runnable task"); + const prerequisite = nodeByTitle(currentGraph, "Prerequisite task"); + const suffix = randomUUID().replaceAll("-", ""); + const triggerName = `test_claim_${suffix}`; + const functionName = `${triggerName}_fn`; + const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const; + + let releaseAdvisoryLock: () => void = () => undefined; + let acquiredAdvisoryLock!: () => void; + let blockerPid = 0; + let functionCreated = false; + let triggerCreated = false; + const beforeDependencies = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } }); + expect(beforeDependencies).toHaveLength(0); + const locked = new Promise((resolve) => { acquiredAdvisoryLock = resolve; }); + const hold = new Promise((resolve) => { releaseAdvisoryLock = resolve; }); + let claim: ReturnType | undefined; + let edit: Promise<{ status: "fulfilled" } | { status: "rejected"; error: unknown }> | undefined; + let editSettled = false; + let lockHolder: Promise | undefined; + try { + await prisma.$executeRawUnsafe(` + CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]}); + RETURN NEW; + END; + $$ + `); + functionCreated = true; + await prisma.$executeRawUnsafe(` + CREATE TRIGGER ${triggerName} + BEFORE UPDATE ON "WorkGraphNode" + FOR EACH ROW WHEN (NEW."projectId" = '${project.id}' AND NEW."owner" IS NOT NULL AND OLD."owner" IS DISTINCT FROM NEW."owner") + EXECUTE FUNCTION ${functionName}(); + `); + triggerCreated = true; + lockHolder = prisma.$transaction(async (transaction) => { + const [backend] = await transaction.$queryRaw>`SELECT pg_backend_pid() AS pid`; + blockerPid = backend!.pid; + await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`; + acquiredAdvisoryLock(); + await hold; + }); + await Promise.race([locked, lockHolder!.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]); + claim = claimReadyWork(slug, "claimant"); + const waitDeadline = Date.now() + 2000; + let claimWaiter: DatabaseLockWaiter | undefined; + while (!claimWaiter && Date.now() < waitDeadline) { + claimWaiter = (await readDatabaseLockWaiters()).find((row) => row.query.toLowerCase().includes("update") && row.query.toLowerCase().includes("workgraphnode") && row.blockingPids.includes(blockerPid)); + if (!claimWaiter) await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(claimWaiter).toBeDefined(); + + edit = updateWorkDependencies(slug, task.id, [prerequisite.id], "database-test").then( + () => ({ status: "fulfilled" as const }), + (error: unknown) => ({ status: "rejected" as const, error }), + ).finally(() => { editSettled = true; }); + const editWaitDeadline = Date.now() + 2000; + let dependencyEditWaiter: DatabaseLockWaiter | undefined; + while (!dependencyEditWaiter && !editSettled && Date.now() < editWaitDeadline) { + dependencyEditWaiter = (await readDatabaseLockWaiters()).find((row) => isProjectCoordinationLockQuery(row.query) && row.blockingPids.includes(claimWaiter!.pid)); + if (!dependencyEditWaiter && !editSettled) await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(editSettled).toBe(false); + expect(dependencyEditWaiter).toBeDefined(); + } finally { + releaseAdvisoryLock?.(); + if (lockHolder) await lockHolder.catch(() => undefined); + if (claim) await claim.catch(() => undefined); + if (edit) await edit.catch(() => undefined); + if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphNode"`); + if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`); + } + + const claimResult = await claim!; + expect(claimResult?.node.id).toBe(task.id); + expect(claimResult?.node.dependencies.map((edge) => edge.dependsOnNodeId)).toEqual(beforeDependencies.map((edge) => edge.dependsOnNodeId)); + const editResult = await edit!; + expect(editResult.status).toBe("rejected"); + if (editResult.status === "rejected") expect(editResult.error).toMatchObject({ message: "WORK_GRAPH_ACTIVE_LEASE" }); + const persisted = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } }); + expect(persisted).toEqual(beforeDependencies); + }, 15_000); + + it("keeps completion audit foreign-key checks compatible with a waiting graph sync", async () => { + const { slug, project, graph } = await createWorkGraphProject("## Work\n- Completing task\n- Other task"); + await setAutonomyPriority(project.id, slug, "Completing task", 100); + const task = nodeByTitle(graph, "Completing task"); + const claim = await claimReadyWork(slug, "completion-worker"); + expect(claim?.node.id).toBe(task.id); + const barrier = await createAuditInsertBarrier(project.id, "work.completed"); + let completion: ReturnType | undefined; + let sync: ReturnType | undefined; + try { + completion = recordWorkCompletion(slug, task.id, "completion-worker", claim!.leaseToken, { summary: "Completion lock-order proof" }); + const completionWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphauditevent") && waiter.blockingPids.includes(barrier.blockerPid)); + expect(completionWaiter).toBeDefined(); + + sync = syncProjectWorkGraph(slug); + const syncWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(completionWaiter!.pid) && waiter.hasProjectRowShareLock); + expect(syncWaiter).toBeDefined(); + + barrier.release(); + const results = await Promise.allSettled([completion, sync]); + expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]); + } finally { + barrier.release(); + await Promise.allSettled([...(completion ? [completion] : []), ...(sync ? [sync] : [])]); + await barrier.cleanup(); + } + }, 15_000); + + it("keeps expired-work audit foreign-key checks compatible with a waiting claim", async () => { + const { slug, project, graph } = await createWorkGraphProject("## Work\n- Expired task\n- Other task"); + await setAutonomyPriority(project.id, slug, "Expired task", 100); + const task = nodeByTitle(graph, "Expired task"); + const initialClaim = await claimReadyWork(slug, "expired-worker"); + expect(initialClaim?.node.id).toBe(task.id); + await prisma.workGraphNode.update({ where: { id: task.id }, data: { leaseExpiresAt: new Date(Date.now() - 60_000) } }); + const barrier = await createAuditInsertBarrier(project.id, "work.reclaimed"); + let reclaim: ReturnType | undefined; + let claimAfterExpiry: ReturnType | undefined; + try { + reclaim = reclaimExpiredWork(slug); + const reclaimWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphauditevent") && waiter.blockingPids.includes(barrier.blockerPid)); + expect(reclaimWaiter).toBeDefined(); + + claimAfterExpiry = claimReadyWork(slug, "reclaim-race-claimant"); + const claimWaiter = await waitForDatabaseLockWaiter((waiter) => isProjectCoordinationLockQuery(waiter.query) && waiter.blockingPids.includes(reclaimWaiter!.pid)); + expect(claimWaiter).toBeDefined(); + + barrier.release(); + const [reclaimResult, claimResult] = await Promise.all([reclaim, claimAfterExpiry]); + expect(reclaimResult.reclaimedCount).toBe(1); + expect(claimResult?.node.id).toBe(task.id); + } finally { + barrier.release(); + await Promise.allSettled([...(reclaim ? [reclaim] : []), ...(claimAfterExpiry ? [claimAfterExpiry] : [])]); + await barrier.cleanup(); + } + }, 15_000); + + it("serializes multi-node reclaim before it can invert locks with graph reconciliation", async () => { + const { slug, project, graph } = await createWorkGraphProject("## Work\n- Task B\n- Task A"); + const taskA = nodeByTitle(graph, "Task A"); + const taskB = nodeByTitle(graph, "Task B"); + const now = Date.now(); + await prisma.workGraphNode.update({ where: { id: taskB.id }, data: { owner: "expired-worker", leaseToken: randomUUID(), leaseExpiresAt: new Date(now - 120_000), executionEligibility: "running", canonicalLifecycle: "running" } }); + await prisma.workGraphNode.update({ where: { id: taskA.id }, data: { owner: "expired-worker", leaseToken: randomUUID(), leaseExpiresAt: new Date(now - 60_000), executionEligibility: "running", canonicalLifecycle: "running" } }); + const reclaimWhere = { projectId: project.id, leaseExpiresAt: { lt: new Date() }, owner: { not: null }, executionEligibility: "running" as const }; + const reclaimOrder = await prisma.workGraphNode.findMany({ where: reclaimWhere, select: { id: true, title: true } }); + expect(reclaimOrder).toHaveLength(2); + const graphOrder = reclaimOrder.map((node) => node.title).reverse(); + await setAutonomyRoadmapOrder(project.id, slug, graphOrder); + + const graphFirstNode = graphOrder[0] === "Task A" ? taskA : taskB; + const barrier = await createWorkGraphNodeUpdateBarrier(graphFirstNode.id); + let sync: ReturnType | undefined; + let reclaim: ReturnType | undefined; + try { + sync = syncProjectWorkGraph(slug); + const syncWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(barrier.blockerPid) && waiter.hasProjectRowShareLock); + expect(syncWaiter).toBeDefined(); + + reclaim = reclaimExpiredWork(slug); + const reclaimProjectWaiter = await waitForDatabaseLockWaiter((waiter) => isProjectCoordinationLockQuery(waiter.query) && waiter.blockingPids.includes(syncWaiter!.pid)); + const reclaimNodeWaiter = reclaimProjectWaiter ? undefined : await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(syncWaiter!.pid)); + expect(reclaimProjectWaiter ?? reclaimNodeWaiter).toBeDefined(); + + barrier.release(); + if (reclaimNodeWaiter) { + const graphReverseWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(reclaimNodeWaiter.pid) && waiter.hasProjectRowShareLock); + expect(graphReverseWaiter).toBeDefined(); + } + const results = await Promise.allSettled([sync, reclaim]); + const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); + expect(results.map((result) => result.status), failures.map((failure) => String(failure.reason)).join("\n")).toEqual(["fulfilled", "fulfilled"]); + } finally { + barrier.release(); + await Promise.allSettled([...(sync ? [sync] : []), ...(reclaim ? [reclaim] : [])]); + await barrier.cleanup(); + } + }, 15_000); + + it("keeps a live lease's derived dependencies until expiry, then applies the latest source edges", async () => { + const { slug, project } = await createWorkGraphProject("## Work\n- Runnable task\n- Prerequisite task"); + await setAutonomyPriority(project.id, slug, "Runnable task", 100); + const graph = await getProjectWorkGraph(slug); + const task = nodeByTitle(graph, "Runnable task"); + const prerequisite = nodeByTitle(graph, "Prerequisite task"); + const claim = await claimReadyWork(slug, "claimant"); + expect(claim?.node.id).toBe(task.id); + expect(claim?.node.dependencies).toHaveLength(0); + + await setAutonomyDependencies(project.id, slug, "Runnable task", ["Prerequisite task"]); + const duringLease = await getProjectWorkGraph(slug); + const leasedTask = nodeByTitle(duringLease, "Runnable task"); + expect(leasedTask.executionEligibility).toBe("running"); + expect(leasedTask.dependencies).toHaveLength(0); + + await prisma.workGraphNode.update({ where: { id: task.id }, data: { leaseExpiresAt: new Date(Date.now() - 60_000) } }); + const afterExpiry = await getProjectWorkGraph(slug); + const blockedTask = nodeByTitle(afterExpiry, "Runnable task"); + expect(blockedTask.executionEligibility).toBe("waiting"); + expect(blockedTask.dependencies.map((edge) => edge.dependsOnNodeId)).toEqual([prerequisite.id]); + const nextClaim = await claimReadyWork(slug, "next-claimant"); + expect(nextClaim?.node.id).toBe(prerequisite.id); + expect(nextClaim?.node.id).not.toBe(task.id); + }, 15_000); + + it("keeps the old complete derived edge set visible until reconciliation commits", async () => { + const { slug, project } = await createWorkGraphProject("## Work\n- Task A\n- Task B\n- Task C"); + await setAutonomyDependencies(project.id, slug, "Task A", ["Task B"]); + const initialGraph = await getProjectWorkGraph(slug); + const taskA = nodeByTitle(initialGraph, "Task A"); + const taskB = nodeByTitle(initialGraph, "Task B"); + const taskC = nodeByTitle(initialGraph, "Task C"); + await setAutonomyDependencies(project.id, slug, "Task A", ["Task C"]); + + let releaseProjectLock!: () => void; + let acquiredProjectLock!: () => void; + let blockerPid = 0; + const projectLocked = new Promise((resolve) => { acquiredProjectLock = resolve; }); + const hold = new Promise((resolve) => { releaseProjectLock = resolve; }); + const lockHolder = prisma.$transaction(async (transaction) => { + const [backend] = await transaction.$queryRaw>`SELECT pg_backend_pid() AS pid`; + blockerPid = backend!.pid; + await transaction.$queryRaw`SELECT "id" FROM "Project" WHERE "id" = ${project.id} FOR UPDATE`; + acquiredProjectLock(); + await hold; + }); + let sync: Promise | undefined; + try { + await Promise.race([projectLocked, lockHolder.then(() => { throw new Error("The project lock holder ended before acquiring its lock."); })]); + let syncFinished = false; + sync = syncProjectWorkGraph(slug).then((result) => { syncFinished = true; return result; }); + const waitDeadline = Date.now() + 5000; + let waitingTransactions: DatabaseLockWaiter[] = []; + while (!waitingTransactions.length && Date.now() < waitDeadline) { + waitingTransactions = (await readDatabaseLockWaiters()).filter((row) => row.blockingPids.includes(blockerPid)); + if (!waitingTransactions.length) await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(waitingTransactions.length).toBeGreaterThan(0); + expect(syncFinished).toBe(false); + const duringSync = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, createdBy: "autonomy-sync" } }); + expect(duringSync.map((edge) => [edge.nodeId, edge.dependsOnNodeId])).toEqual([[taskA.id, taskB.id]]); + } finally { + releaseProjectLock(); + await lockHolder.catch(() => undefined); + if (sync) await sync; + } + + const afterSync = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, createdBy: "autonomy-sync" } }); + expect(afterSync.map((edge) => [edge.nodeId, edge.dependsOnNodeId])).toEqual([[taskA.id, taskC.id]]); + }, 15_000); + + it("rechecks prerequisites before treating an expired lease as reclaimable", async () => { + const { slug, project, graph } = await createWorkGraphProject("## Work\n- Dependent task\n- Prerequisite task"); + const task = nodeByTitle(graph, "Dependent task"); + const prerequisite = nodeByTitle(graph, "Prerequisite task"); + await updateWorkDependencies(slug, task.id, [prerequisite.id], "database-test"); + await prisma.workGraphNode.update({ where: { id: task.id }, data: { + owner: "expired-worker", + leaseToken: randomUUID(), + leaseExpiresAt: new Date(Date.now() - 60_000), + executionEligibility: "running", + canonicalLifecycle: "running", + } }); + + const snapshot = await getProjectWorkGraph(slug); + expect(nodeByTitle(snapshot, "Dependent task").executionEligibility).toBe("waiting"); + expect(nodeByTitle(snapshot, "Dependent task").dependencies.some((edge) => edge.dependsOnNodeId === prerequisite.id)).toBe(true); + const claim = await claimReadyWork(slug, "claimant"); + expect(claim?.node.id).toBe(prerequisite.id); + expect(claim?.node.id).not.toBe(task.id); + }); }); diff --git a/backend/test/gitea-integration.test.ts b/backend/test/gitea-integration.test.ts index 6e12bf1..2891786 100644 --- a/backend/test/gitea-integration.test.ts +++ b/backend/test/gitea-integration.test.ts @@ -107,13 +107,36 @@ describe("Gitea webhook verification", () => { }); describe("Gitea API client", () => { - it("accepts Gitea's empty HTTP 200 merge response without inventing a failed merge", async () => { - const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") - ? new Response(JSON.stringify({ version: "1.27.3" }), { status: 200 }) - : new Response(null, { status: 200 })); + it.each([ + ["1.14.0", "Do"], + ["1.14.7", "Do"], + ["1.25.5", "Do"], + ["1.26.4", "do"], + ["1.27.3", "do"], + ["28.0.0", "do"], + ["28.1.3", "do"], + ] as const)("uses the supported Gitea merge field for %s", async (version, expectedKey) => { + const bodies: Record[] = []; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).endsWith("/version")) return new Response(JSON.stringify({ version }), { status: 200 }); + bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(null, { status: 200 }); + }); const client = new GiteaClient("https://gitea.example.com", "test-token", fetcher as typeof fetch); await expect(client.mergePullRequest("Owner", "Repo", 7, { method: "merge", headCommitId: "a".repeat(40) })).resolves.toBeUndefined(); expect(fetcher).toHaveBeenCalledTimes(2); + expect(bodies[0]?.[expectedKey]).toBe("merge"); + expect(bodies[0]?.[expectedKey === "Do" ? "do" : "Do"]).toBeUndefined(); + }); + + it.each(["1.24.6", "29.0.0", "not-a-version", ""])("fails closed for unsupported Gitea merge version %s", async (version) => { + const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") + ? new Response(JSON.stringify({ version }), { status: 200 }) + : new Response(null, { status: 200 })); + const client = new GiteaClient("https://gitea.example.com", "test-token", fetcher as typeof fetch); + await expect(client.mergePullRequest("Owner", "Repo", 7, { method: "merge", headCommitId: "a".repeat(40) })) + .rejects.toMatchObject({ code: "GITEA_VERSION_UNSUPPORTED" }); + expect(fetcher).toHaveBeenCalledTimes(1); }); it("creates a private initialized repository through the bounded user endpoint", async () => { const fetcher = vi.fn(async () => new Response(JSON.stringify({ @@ -509,11 +532,11 @@ describe("ChatGPT feature delivery approval", () => { it("requires every Gitea Actions workflow for the exact branch head to succeed", () => { const run = (id: number, conclusion: string, headSha = "current-sha"): GiteaActionRun => ({ - id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "push", head_sha: headSha, + id, run_number: id, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "workflow_dispatch", head_sha: headSha, head_branch: "chatgpt/feature", status: "completed", conclusion, html_url: `https://gitea.example.com/runs/${id}`, }); expect(assertSuccessfulActionRuns([run(1, "success"), run(2, "success")], "chatgpt/feature", "current-sha")).toHaveLength(2); - expect(() => assertSuccessfulActionRuns([run(1, "success"), run(2, "failure")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + expect(() => assertSuccessfulActionRuns([run(1, "success"), run(2, "failure")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); expect(() => assertSuccessfulActionRuns([run(1, "success", "stale-sha")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); }); }); diff --git a/backend/test/native-ci-discovery.test.ts b/backend/test/native-ci-discovery.test.ts index 6325c14..b026421 100644 --- a/backend/test/native-ci-discovery.test.ts +++ b/backend/test/native-ci-discovery.test.ts @@ -7,7 +7,7 @@ const branch = "codex/native-ci"; const sha = "a".repeat(40); const context = "CI / build-test (pull_request)"; const run = (id: number, overrides: Partial = {}): GiteaActionRun => ({ - id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml", + id, run_number: id, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", head_sha: sha, head_branch: "", status: "completed", conclusion: "success", html_url: `https://gitea.example/runs/${id}`, ...overrides, }); @@ -25,11 +25,11 @@ describe("native PR validation discovery", () => { }); it.each(["pending", "queued", "waiting", "in_progress"])("does not authorize %s", (status) => { - expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); }); it.each(["cancelled", "canceled", "failure"])("does not authorize a %s job that never started", (conclusion) => { - expect(() => assertSuccessfulActionRuns([run(1, { conclusion })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + expect(() => assertSuccessfulActionRuns([run(1, { conclusion })], branch, sha)).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); }); it("uses the newest attempt of each workflow and preserves all evidence", () => { diff --git a/backend/test/operatingSystemRoutes.test.ts b/backend/test/operatingSystemRoutes.test.ts new file mode 100644 index 0000000..8bef17c --- /dev/null +++ b/backend/test/operatingSystemRoutes.test.ts @@ -0,0 +1,65 @@ +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import { registerOperatingSystemRoutes } from "../src/routes/operatingSystem.js"; + +const mocks = vi.hoisted(() => ({ updateWorkDependencies: vi.fn() })); + +vi.mock("../src/services/workGraph.js", () => ({ + claimReadyWork: vi.fn(), + getProjectWorkGraph: vi.fn(), + listWorkGraphHistory: vi.fn(), + recordWorkCompletion: vi.fn(), + reclaimExpiredWork: vi.fn(), + releaseWork: vi.fn(), + updateWorkDependencies: mocks.updateWorkDependencies, +})); + +const env = loadEnv({ + NODE_ENV: "test", + PROJECTBRAIN_DATA_SOURCE: "database", + DATABASE_URL: "postgresql://fixture:fixture@localhost:5432/projectbrain_fixture", + PROJECTBRAIN_OWNER_USERNAME: "fixture-owner", + PROJECTBRAIN_OWNER_PASSWORD: "fixture-only-owner-password", + PROJECTBRAIN_AUTH_SECRET: "fixture-only-auth-secret-with-at-least-32-characters", +}); + +describe("operating-system work-graph routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps an active work-graph lease conflict to 409 with the authenticated actor", async () => { + mocks.updateWorkDependencies.mockRejectedValue(new Error("WORK_GRAPH_ACTIVE_LEASE")); + const app = Fastify(); + app.decorateRequest("authUser", null); + app.addHook("onRequest", async (request) => { + request.authUser = { + id: "member-id", + username: "authenticated-member", + role: "member", + credentialVersion: 1, + }; + }); + await registerOperatingSystemRoutes(app, { env }); + + try { + const response = await app.inject({ + method: "PUT", + url: "/api/projects/project-1/work-graph/node-1/dependencies", + payload: { dependencyNodeIds: ["node-2"], actor: "forged-caller" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe("WORK_GRAPH_ACTIVE_LEASE"); + expect(mocks.updateWorkDependencies).toHaveBeenCalledWith( + "project-1", + "node-1", + ["node-2"], + "authenticated-member", + ); + } finally { + await app.close(); + } + }); +}); diff --git a/backend/test/remediation-regressions.test.ts b/backend/test/remediation-regressions.test.ts index 6b94599..58e2d73 100644 --- a/backend/test/remediation-regressions.test.ts +++ b/backend/test/remediation-regressions.test.ts @@ -15,9 +15,9 @@ const actionRun = (input: { }): GiteaActionRun => ({ id: input.id, run_number: input.id, - display_title: input.workflow ?? "CI", - path: `.gitea/workflows/${input.workflow ?? "ci.yml"}`, - event: "push", + display_title: input.workflow ?? "Managed validation / full", + path: `.gitea/workflows/${input.workflow ?? "managed-validation.yml"}`, + event: input.workflow ? "pull_request" : "workflow_dispatch", head_sha: input.headSha ?? "current-sha", head_branch: "chatgpt/feature", status: input.status ?? "completed", @@ -46,6 +46,7 @@ describe("review remediation regressions", () => { it("still fails closed when the latest run of any workflow is not successful", () => { expect(() => assertSuccessfulActionRuns([ + actionRun({ id: 9, conclusion: "success" }), actionRun({ id: 10, conclusion: "success", workflow: "quality.yml" }), actionRun({ id: 11, conclusion: "failure", workflow: "security.yml" }), ], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); @@ -53,7 +54,7 @@ describe("review remediation regressions", () => { expect(() => assertSuccessfulActionRuns([ actionRun({ id: 12, conclusion: "success" }), actionRun({ id: 13, conclusion: "", status: "in_progress" }), - ], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + ], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); }); it("rejects shipped placeholder credentials in production", () => { diff --git a/backend/test/runnerClaimHealth.test.ts b/backend/test/runnerClaimHealth.test.ts new file mode 100644 index 0000000..ede9d49 --- /dev/null +++ b/backend/test/runnerClaimHealth.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type { CompactRunner, CompactRunnerJob } from "../src/services/activityRuntime.js"; +import { evaluateRunnerClaimHealth } from "../src/services/runnerClaimHealth.js"; + +const runner = (input: Partial = {}): CompactRunner => ({ + id: 1, + name: "validation-1", + status: "online", + busy: false, + disabled: false, + labels: ["linux-validation"], + ...input, +}); + +const job = (input: Partial = {}): CompactRunnerJob => ({ + id: 99, + runId: 9, + name: "full", + repository: "Jens/projectbrain", + branch: "feature", + status: "queued", + queueState: "runnable", + labels: ["linux-validation"], + completedSteps: 0, + totalSteps: 1, + url: "https://gitea.example/jobs/99", + createdAt: "2026-09-30T00:00:00.000Z", + runnableObservedSince: "2026-09-30T00:00:00.000Z", + claimableObservedSince: "2026-09-30T00:00:00.000Z", + ...input, +}); + +describe("runner claim health", () => { + const now = Date.parse("2026-09-30T00:05:00.000Z"); + + it("marks an old queued job with a compatible idle runner as polling-stalled without inventing a restart action", () => { + const health = evaluateRunnerClaimHealth({ runners: [runner()], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now, stallSeconds: 120 }); + expect(health).toMatchObject({ + status: "stalled", + recoveryOwner: "appops-runner-fleet", + recoveryPolicy: "controller-owned", + counts: { pollingStalled: 1 }, + oldestWaitSeconds: 300, + oldestClaimableWaitSeconds: 300, + }); + expect(health.jobs[0]).toMatchObject({ state: "polling-stalled", compatibleIdleRunnerNames: ["validation-1"] }); + expect(JSON.stringify(health)).not.toContain("container.restart"); + }); + + it("classifies a busy compatible runner as capacity-wait", () => { + const health = evaluateRunnerClaimHealth({ runners: [runner({ busy: true })], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now }); + expect(health.jobs[0]).toMatchObject({ state: "capacity-wait", waitSeconds: 300, claimableWaitSeconds: 0 }); + expect(health.oldestClaimableWaitSeconds).toBe(0); + }); + + it("reports a label block when no online runner can satisfy the job", () => { + const health = evaluateRunnerClaimHealth({ runners: [runner({ labels: ["security"] })], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now }); + expect(health.status).toBe("degraded"); + expect(health.counts.labelBlocked).toBe(1); + }); + + it("reports a polling stall independently of how many compatible idle runners exist", () => { + const health = evaluateRunnerClaimHealth({ + runners: [runner(), runner({ id: 2, name: "validation-2" })], + jobs: [job()], + generatedAt: "2026-09-30T00:05:00.000Z", + }, { now }); + expect(health.counts.pollingStalled).toBe(1); + expect(health.jobs[0].compatibleIdleRunnerNames).toEqual(["validation-1", "validation-2"]); + }); + + it("keeps a newly queued compatible job inside the normal claim grace window", () => { + const health = evaluateRunnerClaimHealth({ + runners: [runner()], + jobs: [job({ + createdAt: "2026-09-30T00:04:30.000Z", + runnableObservedSince: "2026-09-30T00:04:30.000Z", + claimableObservedSince: "2026-09-30T00:04:30.000Z", + })], + generatedAt: "2026-09-30T00:05:00.000Z", + }, { now, stallSeconds: 120 }); + expect(health.status).toBe("healthy"); + expect(health.jobs[0].state).toBe("claimable"); + }); + + it("reports incomplete coverage rather than healthy when queued jobs were omitted from the scan", () => { + const health = evaluateRunnerClaimHealth({ + availability: "live", + runners: [runner()], + jobs: Array.from({ length: 50 }, (_, index) => job({ id: index + 1, createdAt: "2026-09-30T00:04:30.000Z" })), + runnableJobScan: { complete: false, observed: 50, total: 60 }, + generatedAt: "2026-09-30T00:05:00.000Z", + }, { now }); + + expect(health).toMatchObject({ + status: "incomplete", + coverage: { complete: false, observed: 50, total: 60 }, + counts: { queued: 50, pollingStalled: 0 }, + }); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 0ae3362..4771055 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,7 @@ services: PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: ${PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES:-true} PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: ${PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS:-60} PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: ${PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS:-5400} + PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS: ${PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS:-120} PROJECTBRAIN_HUB_SERVICE_TOKEN: ${PROJECTBRAIN_HUB_SERVICE_TOKEN:-} PROJECTBRAIN_PUBLIC_URL: ${PROJECTBRAIN_PUBLIC_URL:-} PROJECTBRAIN_ENTRA_TENANT_ID: ${PROJECTBRAIN_ENTRA_TENANT_ID:-} diff --git a/e2e/attention-auto-mode.spec.ts b/e2e/attention-auto-mode.spec.ts index ae40d06..d72effb 100644 --- a/e2e/attention-auto-mode.spec.ts +++ b/e2e/attention-auto-mode.spec.ts @@ -39,6 +39,105 @@ const autoModeState = (): AutoModeState => ({ updatedBy: "owner", }); +const queueState = (manualOrder = false): AutoModeState => { + const base = autoModeState(); + const roadmap = base.roadmap!; + return { + ...base, + roadmap: { + ...roadmap, + phases: roadmap.phases.map((phase) => ({ + ...phase, + items: [ + { id: "item-a", title: "Item A", status: "pending", attempts: 0, ...(manualOrder ? { manualOrder: 2 } : {}) }, + { id: "item-b", title: "Item B", status: "pending", attempts: 0, ...(manualOrder ? { manualOrder: 1 } : {}) }, + ], + })), + }, + }; +}; + +const expectQueueOrder = async (page: import("@playwright/test").Page, firstTitle: string) => { + const rows = page.locator('section[aria-labelledby="auto-queue-order-heading"] > div.space-y-2 > div'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0)).toContainText(firstTitle); +}; + +const exerciseStaleQueueRead = async ( + page: import("@playwright/test").Page, + mutation: "reorder" | "reset", +) => { + const beforeMutation = queueState(mutation === "reset"); + let persistedState = beforeMutation; + let getCount = 0; + let releaseStaleGet!: () => void; + let markStaleGetStarted!: () => void; + let releaseMutation!: () => void; + let markMutationStarted!: () => void; + const staleGetGate = new Promise((resolve) => { releaseStaleGet = resolve; }); + const staleGetStarted = new Promise((resolve) => { markStaleGetStarted = resolve; }); + const mutationGate = new Promise((resolve) => { releaseMutation = resolve; }); + const mutationStarted = new Promise((resolve) => { markMutationStarted = resolve; }); + + await page.route(`**/api/projects/${projectId}/autonomy**`, async (route) => { + const request = route.request(); + if (request.method() === "GET") { + getCount += 1; + if (getCount === 2) { + markStaleGetStarted(); + await staleGetGate; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(beforeMutation)) }); + return; + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(persistedState)) }); + return; + } + if (mutation === "reorder" && request.method() === "PUT") { + const { itemIds } = request.postDataJSON() as { itemIds: string[] }; + markMutationStarted(); + await mutationGate; + const positions = new Map(itemIds.map((id, index) => [id, index])); + persistedState = { + ...beforeMutation, + roadmap: { + ...beforeMutation.roadmap!, + phases: beforeMutation.roadmap!.phases.map((phase) => ({ + ...phase, + items: phase.items.map((item) => positions.has(item.id) + ? { ...item, manualOrder: positions.get(item.id) } + : item), + })), + }, + }; + } else if (mutation === "reset" && request.method() === "DELETE") { + markMutationStarted(); + await mutationGate; + persistedState = queueState(); + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(persistedState)) }); + }); + + await page.goto(`/projects/${projectId}/auto`); + await expect(page.getByRole("heading", { name: "Auto Mode", exact: true })).toBeVisible(); + await page.getByText("Roadmap en wachtrij", { exact: true }).click(); + await expectQueueOrder(page, mutation === "reset" ? "Item B" : "Item A"); + + await page.getByRole("button", { name: "Vernieuwen", exact: true }).click(); + await staleGetStarted; + if (mutation === "reorder") { + await page.getByRole("button", { name: "Item B omhoog" }).click(); + } else { + await page.getByRole("button", { name: "Automatische volgorde herstellen" }).click(); + } + await mutationStarted; + await expect(page.getByText("Opslaan…", { exact: true })).toBeVisible(); + + releaseMutation(); + await expectQueueOrder(page, mutation === "reset" ? "Item A" : "Item B"); + releaseStaleGet(); + await expectQueueOrder(page, mutation === "reset" ? "Item A" : "Item B"); +}; + test("attention inbox exposes every visible item as an actionable destination", async ({ page }) => { await page.goto(`/projects/${projectId}/attention`); await expect(page.getByRole("heading", { name: "Jouw aandacht", exact: true })).toBeVisible(); @@ -110,3 +209,11 @@ test("Auto Mode distinguishes queued work from real execution and protects roadm const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]); }); + +test("Auto Mode ignores an older read after saving a reordered queue", async ({ page }) => { + await exerciseStaleQueueRead(page, "reorder"); +}); + +test("Auto Mode ignores an older read after resetting queue order", async ({ page }) => { + await exerciseStaleQueueRead(page, "reset"); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf578fe..65600c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,9 @@ overrides: esbuild: 0.28.2 hono: ^4.13.5 qs: ^6.16.0 - fast-uri@>=3.0.0 <4.0.0: 3.1.7 - fast-uri@>=4.0.0 <5.0.0: 4.1.4 + fast-uri@>=3.0.0 <4.0.0: 3.1.8 + fast-uri@>=4.0.0 <5.0.0: 4.1.5 + ip-address@>=10.0.0 <11.0.0: 10.7.2 importers: @@ -2156,11 +2157,11 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - fast-uri@3.1.7: - resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} - fast-uri@4.1.4: - resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==} + fast-uri@4.1.5: + resolution: {integrity: sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg==} fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -2349,8 +2350,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.5.0: - resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -3654,7 +3655,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 3.1.7 + fast-uri: 3.1.8 '@fastify/cookie@11.1.2': dependencies: @@ -4808,7 +4809,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5235,7 +5236,7 @@ snapshots: dependencies: debug: 4.4.3 express: 5.2.1 - ip-address: 10.5.0 + ip-address: 10.7.2 transitivePeerDependencies: - supports-color @@ -5291,7 +5292,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 4.1.4 + fast-uri: 4.1.5 json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 @@ -5301,9 +5302,9 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 - fast-uri@3.1.7: {} + fast-uri@3.1.8: {} - fast-uri@4.1.4: {} + fast-uri@4.1.5: {} fastify-plugin@5.1.0: {} @@ -5491,7 +5492,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.5.0: {} + ip-address@10.7.2: {} ipaddr.js@1.9.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f21ea3d..570ca62 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,8 +20,9 @@ overrides: esbuild: 0.28.2 hono: ^4.13.5 qs: ^6.16.0 - 'fast-uri@>=3.0.0 <4.0.0': 3.1.7 - 'fast-uri@>=4.0.0 <5.0.0': 4.1.4 + 'fast-uri@>=3.0.0 <4.0.0': 3.1.8 + 'fast-uri@>=4.0.0 <5.0.0': 4.1.5 + 'ip-address@>=10.0.0 <11.0.0': 10.7.2 peerDependencyRules: allowedVersions: diff --git a/src/features/autonomy/components/SortableWorkQueue.tsx b/src/features/autonomy/components/SortableWorkQueue.tsx new file mode 100644 index 0000000..6630638 --- /dev/null +++ b/src/features/autonomy/components/SortableWorkQueue.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import { ArrowDown, ArrowUp, GripVertical, LockKeyhole } from "lucide-react"; +import { Badge, Button } from "@/components/ui"; +import type { AutoModeItem } from "@/shared/api/autonomyApi"; + +export type SortableQueueEntry = { + item: AutoModeItem; + phaseTitle: string; + blockedReason?: string; +}; + +export const SortableWorkQueue = ({ + entries, + saving, + manualOrdering, + onReorder, + onReset, +}: { + entries: SortableQueueEntry[]; + saving: boolean; + manualOrdering: boolean; + onReorder: (itemIds: string[]) => void; + onReset: () => void; +}) => { + const [draggedId, setDraggedId] = React.useState(); + const [dragOverId, setDragOverId] = React.useState(); + + const move = React.useCallback((itemId: string, targetIndex: number) => { + const currentIndex = entries.findIndex((entry) => entry.item.id === itemId); + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= entries.length || currentIndex === targetIndex || saving) return; + const ids = entries.map((entry) => entry.item.id); + const [moved] = ids.splice(currentIndex, 1); + ids.splice(targetIndex, 0, moved); + onReorder(ids); + }, [entries, onReorder, saving]); + + if (!entries.length) { + return
Geen pending committed werk om te prioriteren.
; + } + + return
+
+
+

Uitvoeringsvolgorde

+

{manualOrdering ? "Dit is de expliciete uitvoeringsvolgorde voor pending werk; dependencies blijven altijd een harde grens." : "Dit toont de roadmapvolgorde. ProjectBrain kiest automatisch de beste veilige volgende stap; sleep een item om een expliciete volgorde vast te leggen."}

+
+
+ {manualOrdering ? : null} + {saving ? "Opslaan…" : manualOrdering ? "Handmatige volgorde" : `${entries.length} in wachtrij`} +
+
+
+ {entries.map((entry, index) => { + const isDragged = draggedId === entry.item.id; + const isOver = dragOverId === entry.item.id && draggedId !== entry.item.id; + return
{ + if (!draggedId || saving) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setDragOverId(entry.item.id); + }} + onDrop={(event) => { + event.preventDefault(); + if (!draggedId || saving) return; + move(draggedId, index); + setDraggedId(undefined); + setDragOverId(undefined); + }} + className={`flex min-w-0 items-center gap-2 rounded-lg border bg-background p-2 transition ${isDragged ? "opacity-50" : ""} ${isOver ? "border-primary ring-1 ring-primary/30" : ""}`} + > + + {index + 1} +
+
+

{entry.item.title}

+ {entry.blockedReason ? {entry.blockedReason} : null} +
+

{entry.phaseTitle}{entry.item.source ? ` · ${entry.item.source.replaceAll("_", " ")}` : ""}

+
+
+ + +
+
; + })} +
+
; +}; diff --git a/src/features/autonomy/pages/AutoMode.tsx b/src/features/autonomy/pages/AutoMode.tsx index 284d721..88a2d70 100644 --- a/src/features/autonomy/pages/AutoMode.tsx +++ b/src/features/autonomy/pages/AutoMode.tsx @@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { ArrowRight, Bot, BrainCircuit, CheckCircle2, ListTodo, RefreshCw, RotateCcw, ShieldAlert, Upload } from "lucide-react"; import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Progress, Switch, Textarea } from "@/components/ui"; import { autonomyApi, type AutoModePhase, type AutoModeState } from "@/shared/api/autonomyApi"; +import { SortableWorkQueue } from "@/features/autonomy/components/SortableWorkQueue"; import { ApiError } from "@/shared/api/httpClient"; import { autoModeItemLabel, autoModePhaseLabel, autoModeStatusLabel, getAutoModeOperationalState } from "@/shared/lib/autoModePresentation"; import { autonomyProfileDefinitions, autonomyProfileOrder } from "@/shared/lib/autonomyProfiles"; @@ -19,6 +20,7 @@ const AutoMode = () => { const [state, setState] = React.useState(); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); + const [queueSaving, setQueueSaving] = React.useState(false); const [error, setError] = React.useState(); const [roadmapTitle, setRoadmapTitle] = React.useState(""); const [roadmapMarkdown, setRoadmapMarkdown] = React.useState(""); @@ -71,25 +73,111 @@ const AutoMode = () => { }; const savePolicy = async () => { if (await configure(policy)) showSuccess("Autonomiebeleid opgeslagen"); }; - const phases = state?.roadmap?.phases ?? []; - const items = phases.flatMap((phase) => phase.items); + const phases = React.useMemo(() => state?.roadmap?.phases ?? [], [state?.roadmap?.phases]); + const items = React.useMemo(() => phases.flatMap((phase) => phase.items), [phases]); const completedItems = items.filter((item) => item.status === "completed").length; const openItems = items.filter((item) => item.status !== "completed"); const progress = items.length ? Math.round((completedItems / items.length) * 100) : state?.status === "completed" ? 100 : 0; const activePhase = state?.roadmap ? phases[state.roadmap.currentPhaseIndex] : undefined; const inProgressItem = activePhase?.items.find((item) => item.status === "in_progress"); - const activeItem = inProgressItem ?? activePhase?.items.find((item) => item.status !== "completed"); - const nextItem = phases.flatMap((phase) => phase.items).find((item) => item.status === "pending"); + const queueEntries = React.useMemo(() => { + const completedIds = new Set(items.filter((item) => item.status === "completed").map((item) => item.id)); + return phases.flatMap((phase, phaseIndex) => phase.items.map((item, itemIndex) => ({ + item, + phaseTitle: phase.title, + phaseIndex, + itemIndex, + blockedReason: item.waitingExternal + ? "wacht extern" + : (item.dependsOn ?? []).some((dependencyId) => !completedIds.has(dependencyId)) + ? "dependency" + : undefined, + }))) + .filter((entry) => entry.item.status === "pending" && entry.item.scope !== "future" && entry.item.scope !== "stretch") + .sort((left, right) => { + const leftOrder = left.item.manualOrder; + const rightOrder = right.item.manualOrder; + if (typeof leftOrder === "number" || typeof rightOrder === "number") { + if (typeof leftOrder !== "number") return 1; + if (typeof rightOrder !== "number") return -1; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + } + return left.phaseIndex - right.phaseIndex || left.itemIndex - right.itemIndex; + }); + }, [items, phases]); + const queueHasManualOrder = queueEntries.some((entry) => typeof entry.item.manualOrder === "number"); + const nextItem = queueHasManualOrder ? queueEntries.find((entry) => !entry.blockedReason)?.item : undefined; const operational = state ? getAutoModeOperationalState(state) : undefined; const policyDirty = Boolean(state && (state.profile !== policy.profile || state.autoBrainstorm !== policy.autoBrainstorm || state.maxBrainstormIterations !== policy.maxBrainstormIterations || state.maxConsecutiveFailures !== policy.maxConsecutiveFailures || state.leaseMinutes !== policy.leaseMinutes || state.maxWorkItemsPerRun !== policy.maxWorkItemsPerRun || state.maxRunMinutes !== policy.maxRunMinutes || state.maxRecoveryMinutes !== policy.maxRecoveryMinutes)); const updatePolicyNumber = (key: "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes", value: string, minimum: number, maximum: number) => { const parsed = Number(value); if (Number.isFinite(parsed)) setPolicy((current) => ({ ...current, profile: "custom", [key]: Math.max(minimum, Math.min(maximum, Math.trunc(parsed))) })); }; const selectProfile = (profile: AutoModeState["profile"]) => { const settings = autonomyProfileDefinitions[profile].settings; setPolicy((current) => settings ? ({ ...current, profile, ...settings }) : ({ ...current, profile: "custom" })); }; + const reorderQueue = async (itemIds: string[]) => { + if (!projectId || !state?.roadmap || queueSaving) return; + const previous = state; + const positions = new Map(itemIds.map((id, index) => [id, index])); + setState({ + ...state, + roadmap: { + ...state.roadmap, + phases: state.roadmap.phases.map((phase) => ({ + ...phase, + items: phase.items.map((item) => positions.has(item.id) ? { ...item, manualOrder: positions.get(item.id) } : item), + })), + }, + }); + setQueueSaving(true); + setError(undefined); + try { + const next = await autonomyApi.reorderQueue(projectId, itemIds); + loadGeneration.current += 1; + setLoading(false); + setState(next); + showSuccess("Wachtrijvolgorde opgeslagen"); + } catch (nextError) { + setState(previous); + setError(readableError(nextError)); + void load(true); + } finally { + setQueueSaving(false); + } + }; + + const resetQueueOrder = async () => { + if (!projectId || !state?.roadmap || queueSaving) return; + const previous = state; + setState({ + ...state, + roadmap: { + ...state.roadmap, + phases: state.roadmap.phases.map((phase) => ({ + ...phase, + items: phase.items.map((item) => ({ ...item, manualOrder: undefined })), + })), + }, + }); + setQueueSaving(true); + setError(undefined); + try { + const next = await autonomyApi.resetQueueOrder(projectId); + loadGeneration.current += 1; + setLoading(false); + setState(next); + showSuccess("Automatische prioritering hersteld"); + } catch (nextError) { + setState(previous); + setError(readableError(nextError)); + void load(true); + } finally { + setQueueSaving(false); + } + }; + React.useEffect(() => { if (!projectId) return; - const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !policyDirty) void load(true); }, 10_000); + const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !queueSaving && !policyDirty) void load(true); }, 10_000); return () => window.clearInterval(interval); - }, [load, policyDirty, projectId, saving]); + }, [load, policyDirty, projectId, queueSaving, saving]); if (loading && !state) return
Auto Mode laden…
; const controllerClass = operational?.tone === "blocked" ? "border-destructive/40" : operational?.tone === "retry" ? "border-amber-300" : operational?.tone === "active" ? "border-primary/50" : operational?.tone === "complete" ? "border-emerald-300" : "border-dashed"; @@ -104,7 +192,7 @@ const AutoMode = () => {
{operational.tone === "blocked" ? : operational.tone === "retry" ? : }
{operational.label}Auto {state.enabled ? "aan" : "uit"}

{operational.headline}

{operational.detail}

void configure({ enabled })} />
{state.enabled ? : null}
-

Nu

{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}

Hierna

{nextItem?.title ?? (openItems.length ? activePhase?.title ?? "Volgende veilige stap wordt bepaald" : "Geen open committed werk")}

Voortgang

{completedItems}/{items.length} · {progress}%

Open

{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}

{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}

+

Nu

{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}

Hierna

{nextItem?.title ?? (openItems.length ? queueHasManualOrder ? activePhase?.title ?? "Volgende handmatige stap wordt bepaald" : "Slimme scheduler bepaalt de volgende veilige stap" : "Geen open committed werk")}

Voortgang

{completedItems}/{items.length} · {progress}%

Open

{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}

{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}

Repositorywerk wordt atomair geclaimd via projectbrain/auto/<work-id>. Een item wordt pas voltooid na succesvolle exact-head Gitea Actions-validatie en merge van de canonieke PR. Leases en callbacks blijven alleen voor compatibiliteit en herstel.

: null} @@ -112,7 +200,7 @@ const AutoMode = () => { {state?.blocker?.kind === "retryable" ?

Automatisch herstel

{state.blocker.reason} ProjectBrain probeert dit automatisch opnieuw; jouw actie is niet nodig.

{state.blocker.retryAt ?

Opnieuw uitvoerbaar vanaf {new Date(state.blocker.retryAt).toLocaleString("nl-BE")}

: null}
: null} {state?.blocker?.kind === "human" ?

Jouw beslissing of toegang nodig

{state.blocker.reason}

: null} -
Roadmap en wachtrij

Committed werk

{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}

{progress}%
{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return

{index + 1}. {phase.title}

{phase.objective}

{autoModePhaseLabel[phase.status]}

{done}/{phase.items.length} items

{phase.items.length ?
{phase.items.map((item) =>
{item.status === "completed" ? : }{item.title}

{autoModeItemLabel[item.status]} · {item.attempts} poging(en)

)}
: null}
; })}{!items.length ? : null}
+
Roadmap en wachtrij
void reorderQueue(itemIds)} onReset={() => void resetQueueOrder()} />

Committed werk

{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}

{progress}%
{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return

{index + 1}. {phase.title}

{phase.objective}

{autoModePhaseLabel[phase.status]}

{done}/{phase.items.length} items

{phase.items.length ?
{phase.items.map((item) =>
{item.status === "completed" ? : }{item.title}

{autoModeItemLabel[item.status]} · {item.attempts} poging(en)

)}
: null}
; })}{!items.length ? : null}
Autonomiebeleid en geavanceerde instellingen
AutonomieprofielRoutinewerk automatiseren zonder echte risico- of productbeslissingen te verbergen.
{autonomyProfileOrder.map((profile) => { const definition = autonomyProfileDefinitions[profile]; return ; })}
Geavanceerde grenzen
updatePolicyNumber("maxBrainstormIterations", event.target.value, 1, 10)} />
updatePolicyNumber("maxConsecutiveFailures", event.target.value, 1, 10)} />
updatePolicyNumber("leaseMinutes", event.target.value, 5, 120)} />
updatePolicyNumber("maxWorkItemsPerRun", event.target.value, 1, 20)} />
updatePolicyNumber("maxRunMinutes", event.target.value, 5, 55)} />
updatePolicyNumber("maxRecoveryMinutes", event.target.value, 15, 1440)} />

Zoek na concrete taken nog naar echte gaten.

setPolicy((current) => ({ ...current, profile: "custom", autoBrainstorm: checked }))} />
Roadmap handmatig importerenAlleen voor geavanceerd beheer; gewone opdrachten gaan automatisch naar de duurzame wachtrij.
setRoadmapTitle(event.target.value)} />