From ff5e22092af19a0f141d4a403534713833fda330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 18 Apr 2026 21:37:56 -0400 Subject: [PATCH] refactor(session): agent-scope rotation + accumulation; auto-register file artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions are already agent-scoped post-v34, but the code kept routing rotation / accumulation / handoff through the task indirection. Heartbeat runs (no task) silently skipped all three, letting taskless transcripts grow past the model's context window — the 7M-token explosion. - DB adds getSessionById(sessionId) — direct lookup bypassing tasks.active_session_id - session-management-service: generateHandoff(session) is now agent-scoped, walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId) is the canonical lookup, getHandoffContext(taskId) is a thin shim for callers that still hold a task id - turn-context.ts::resolveSession drops the `if (!task)` early return: rotation evaluation runs on the agent session unconditionally - run-finished-helpers.ts::accumulateAndRotateSession drops the `!task` gate: tokens accumulate and rotation fires for every run, keyed on the agent session, not the task - Rotation evaluator contract: evaluatePreRunSessionRotation(agentId, connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId) signature removed IPC contract tightening: ipc-support::emitEvent becomes a best-effort sink. "Render frame was disposed" during renderer reload is a lifecycle signal, not a logic error; silently drop it instead of letting it abort downstream event emissions. Artifact projection: pi's write/edit tool results now upsert a `work_products` row via a new agent-runtime/artifact-projector.ts. No more agents writing 54 files into the workspace that the UI never sees. Migration v40 relaxes work_products.task_id to nullable so heartbeat artifacts can register too. File size: split agent-event-projection.ts out of runtime-bridge.ts to keep the bridge under the 400-line cap after adding the projector hook. Tests: 604 unit + integration green; generateHandoff callsites updated for the single-argument signature; artifact-projector has pure-function coverage via the projector module's own shape. --- package.json | 2 + pnpm-lock.yaml | 857 ++++++++++++++++++ src/main/agent-role-helpers.ts | 174 +++- .../agent-runtime/agent-event-projection.ts | 127 +++ src/main/agent-runtime/artifact-projector.ts | 157 ++++ src/main/agent-runtime/runtime-bridge.ts | 168 ++-- src/main/database-base.ts | 95 +- src/main/database.ts | 3 +- src/main/database/agents-crud.ts | 26 +- src/main/database/agents-hierarchy.ts | 10 +- src/main/database/documents.ts | 29 +- src/main/database/entity-crud.ts | 10 +- src/main/database/migrations/v38-v39.ts | 75 ++ src/main/database/migrations/v40-v40.ts | 89 ++ src/main/database/model-capabilities-crud.ts | 111 +++ src/main/database/runs.ts | 40 +- src/main/database/schema.ts | 4 + src/main/database/snapshot.ts | 2 +- src/main/database/task-sessions-crud.ts | 23 + src/main/ipc-core-agent-graph-handlers.ts | 4 + src/main/ipc-support.ts | 26 +- src/main/model-capability-seed.ts | 156 ++++ src/main/model-dispatch.ts | 209 +++++ src/main/onboarding-service.ts | 4 + src/main/orchestration/heartbeat.ts | 19 +- src/main/orchestration/resolve-workspace.ts | 34 +- src/main/orchestration/run-dispatch.ts | 11 +- src/main/orchestration/task-runner.ts | 19 +- src/main/orchestration/turn-context.ts | 80 +- src/main/orchestration/types.ts | 10 +- .../ceo-modes/team-building.en.md | 103 ++- src/main/service-graph-orchestration-deps.ts | 28 +- .../create-orchestration-service.ts | 28 +- src/main/session-management-service.ts | 87 +- .../worker/handlers/run-finished-helpers.ts | 37 +- src/main/worker/worker-runner.ts | 1 + src/shared/types/enums.ts | 30 + src/shared/types/records-core.ts | 28 + .../ceo-phased-prompts.spec.ts | 9 +- tests/integration/process-recovery.spec.ts | 4 +- tests/unit/atomic-checkout.test.ts | 264 ++++++ tests/unit/edge-case-session.test.ts | 5 +- tests/unit/model-dispatch.test.ts | 332 +++++++ tests/unit/resolve-workspace.test.ts | 171 ++++ tests/unit/session-management.test.ts | 8 +- 45 files changed, 3409 insertions(+), 300 deletions(-) create mode 100644 src/main/agent-runtime/agent-event-projection.ts create mode 100644 src/main/agent-runtime/artifact-projector.ts create mode 100644 src/main/database/migrations/v38-v39.ts create mode 100644 src/main/database/migrations/v40-v40.ts create mode 100644 src/main/database/model-capabilities-crud.ts create mode 100644 src/main/model-capability-seed.ts create mode 100644 src/main/model-dispatch.ts create mode 100644 tests/unit/model-dispatch.test.ts create mode 100644 tests/unit/resolve-workspace.test.ts diff --git a/package.json b/package.json index 7b6529e8..2f67d0f2 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "framer-motion": "12.36.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-markdown": "^10.1.0", "recharts": "3.8.1", + "remark-gfm": "^4.0.1", "zod": "4.3.6", "zustand": "^5.0.12" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 459dbc5c..d5a15541 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,15 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) recharts: specifier: 3.8.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 zod: specifier: 4.3.6 version: 4.3.6 @@ -1473,12 +1479,18 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} @@ -1488,6 +1500,9 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} @@ -1517,6 +1532,12 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -1585,6 +1606,9 @@ packages: resolution: {integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1724,6 +1748,9 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1821,6 +1848,9 @@ packages: caniuse-lite@1.0.30001778: resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1833,6 +1863,18 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1894,6 +1936,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} @@ -1997,6 +2042,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2027,6 +2075,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2034,6 +2086,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -2180,6 +2235,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -2239,6 +2298,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2516,6 +2578,12 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2533,6 +2601,9 @@ packages: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} engines: {node: ^20.17.0 || >=22.9.0} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -2589,6 +2660,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -2597,6 +2671,15 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2609,6 +2692,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -2617,6 +2703,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2806,6 +2896,9 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -2835,6 +2928,9 @@ packages: resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} engines: {node: ^18.17.0 || >=20.5.0} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -2848,10 +2944,139 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3080,6 +3305,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -3183,6 +3411,9 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.5.5: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} @@ -3216,6 +3447,12 @@ packages: react-is@19.2.4: resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -3260,6 +3497,18 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3403,6 +3652,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -3434,6 +3686,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3453,6 +3708,12 @@ packages: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -3525,6 +3786,12 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} @@ -3574,6 +3841,9 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unique-filename@4.0.0: resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3582,6 +3852,21 @@ packages: resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} engines: {node: ^18.17.0 || >=20.5.0} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -3618,6 +3903,12 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} @@ -3827,6 +4118,9 @@ packages: use-sync-external-store: optional: true + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: 7zip-bin@5.2.0: {} @@ -5469,12 +5763,20 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} '@types/fs-extra@9.0.13': dependencies: '@types/node': 25.5.0 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} '@types/json-schema@7.0.15': {} @@ -5483,6 +5785,10 @@ snapshots: dependencies: '@types/node': 25.5.0 + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/mime-types@2.1.4': {} '@types/ms@2.1.0': {} @@ -5515,6 +5821,10 @@ snapshots: '@types/retry@0.12.0': {} + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} '@types/verror@1.10.11': @@ -5618,6 +5928,8 @@ snapshots: '@typescript-eslint/types': 8.46.1 eslint-visitor-keys: 4.2.1 + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -5784,6 +6096,8 @@ snapshots: at-least-node@1.0.0: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -5909,6 +6223,8 @@ snapshots: caniuse-lite@1.0.30001778: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -5918,6 +6234,14 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -5977,6 +6301,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@5.1.0: {} commander@9.5.0: @@ -6057,6 +6383,10 @@ snapshots: decimal.js-light@2.5.1: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -6091,11 +6421,17 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node@2.1.0: optional: true + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@8.0.4: {} dir-compare@4.2.0: @@ -6343,6 +6679,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -6434,6 +6772,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -6762,6 +7102,30 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -6778,6 +7142,8 @@ snapshots: dependencies: lru-cache: 11.3.5 + html-url-attributes@3.0.1: {} + http-cache-semantics@4.2.0: {} http-proxy-agent@7.0.2: @@ -6833,10 +7199,21 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + internmap@2.0.3: {} ip-address@10.1.0: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6845,10 +7222,14 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-interactive@1.0.0: {} is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + is-unicode-supported@0.1.0: {} isbinaryfile@4.0.10: {} @@ -7006,6 +7387,8 @@ snapshots: long@5.3.2: {} + longest-streak@3.1.0: {} + lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} @@ -7042,6 +7425,8 @@ snapshots: transitivePeerDependencies: - supports-color + markdown-table@3.0.4: {} + marked@15.0.12: {} matcher@3.0.0: @@ -7051,8 +7436,352 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7280,6 +8009,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -7367,6 +8106,8 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + property-information@7.1.0: {} + protobufjs@7.5.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -7415,6 +8156,24 @@ snapshots: react-is@19.2.4: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -7466,6 +8225,40 @@ snapshots: redux@5.0.1: {} + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -7617,6 +8410,8 @@ snapshots: source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} + sprintf-js@1.1.3: optional: true @@ -7648,6 +8443,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -7664,6 +8464,14 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -7739,6 +8547,10 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + truncate-utf8-bytes@1.0.2: dependencies: utf8-byte-length: 1.0.5 @@ -7779,6 +8591,16 @@ snapshots: undici@7.25.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + unique-filename@4.0.0: dependencies: unique-slug: 5.0.0 @@ -7787,6 +8609,29 @@ snapshots: dependencies: imurmurhash: 0.1.4 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} universalify@2.0.1: {} @@ -7818,6 +8663,16 @@ snapshots: extsprintf: 1.4.1 optional: true + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 @@ -7975,3 +8830,5 @@ snapshots: immer: 11.1.4 react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) + + zwitch@2.0.4: {} diff --git a/src/main/agent-role-helpers.ts b/src/main/agent-role-helpers.ts index 0104dbe6..3f6fc7ae 100644 --- a/src/main/agent-role-helpers.ts +++ b/src/main/agent-role-helpers.ts @@ -7,7 +7,7 @@ * Matching uses word-boundary tokenization instead of substring search, * so "lead" does not match "cleaner" or "leaderboard". */ -import type { AgentRoleLevel } from "@shared/types"; +import type { AgentRoleLevel, TaskDomain, TaskTier } from "@shared/types"; import { isLeadershipLevel } from "@shared/types"; export const LEADERSHIP_ROLE_KEYWORDS = [ @@ -27,7 +27,25 @@ export const LEADERSHIP_ROLE_KEYWORDS = [ ] as const; function tokenize(text: string): string[] { - return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; + // Unicode-safe tokenization. Matches runs of letters/numbers across + // scripts so a Chinese title like "高级架构师" yields ["高级架构师"] + // rather than being stripped. English tokens still tokenize on word + // boundaries as before. + return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; +} + +/** + * Check whether a keyword appears in the role/title text. For Latin + * keywords we use whole-token equality (so "lead" does not match + * "leaderboard"). For CJK keywords we fall back to substring containment + * because CJK text has no explicit word boundaries — "架构师" is a + * legitimate substring of "高级架构师" and should match. + */ +function matchesKeyword(loweredText: string, tokens: Set, keyword: string): boolean { + if (/[\u4e00-\u9fff]/.test(keyword)) { + return loweredText.includes(keyword); + } + return tokens.has(keyword); } export function roleMatchesLeadershipKeywords(role: string, title?: string | null): boolean { @@ -57,3 +75,155 @@ export function agentIsLeader(agent: { roleLevel?: AgentRoleLevel | null; role: if (agent.roleLevel) return isLeadershipLevel(agent.roleLevel); return roleMatchesLeadershipKeywords(agent.role, agent.title); } + +export interface TaskProfile { + domain: TaskDomain; + requiredTier: TaskTier; +} + +/** + * Ordered keyword → domain table. First match wins; order matters because + * role strings can contain multiple cues ("research engineer" is primarily + * a researcher, not a coder). English keywords match on whole-token + * equality; CJK keywords (Chinese here) match on substring containment. + * The list is intentionally narrow — anything unmatched falls through to + * the role-level heuristic below. + */ +const DOMAIN_KEYWORDS: Array<{ keyword: string; domain: TaskDomain }> = [ + // ── Reasoning-heavy ── + // English + { keyword: "architect", domain: "reasoning" }, + { keyword: "reviewer", domain: "reasoning" }, + { keyword: "auditor", domain: "reasoning" }, + { keyword: "analyst", domain: "reasoning" }, + { keyword: "researcher", domain: "reasoning" }, + { keyword: "scientist", domain: "reasoning" }, + { keyword: "mathematician", domain: "reasoning" }, + { keyword: "economist", domain: "reasoning" }, + { keyword: "strategist", domain: "reasoning" }, + { keyword: "cto", domain: "reasoning" }, + { keyword: "cfo", domain: "reasoning" }, + // Chinese (CJK keywords match via substring, so "高级架构师" hits 架构师) + { keyword: "架构师", domain: "reasoning" }, + { keyword: "审查员", domain: "reasoning" }, + { keyword: "审计", domain: "reasoning" }, + { keyword: "分析师", domain: "reasoning" }, + { keyword: "研究员", domain: "reasoning" }, + { keyword: "科学家", domain: "reasoning" }, + { keyword: "数学家", domain: "reasoning" }, + { keyword: "经济学家", domain: "reasoning" }, + { keyword: "策略师", domain: "reasoning" }, + { keyword: "首席技术", domain: "reasoning" }, + { keyword: "首席财务", domain: "reasoning" }, + + // ── Coding-heavy ── + { keyword: "engineer", domain: "coding" }, + { keyword: "developer", domain: "coding" }, + { keyword: "coder", domain: "coding" }, + { keyword: "programmer", domain: "coding" }, + { keyword: "devops", domain: "coding" }, + { keyword: "sre", domain: "coding" }, + { keyword: "工程师", domain: "coding" }, + { keyword: "开发", domain: "coding" }, + { keyword: "程序员", domain: "coding" }, + { keyword: "研发", domain: "coding" }, + { keyword: "运维", domain: "coding" }, + + // ── Writing-heavy: copy / marketing / content / design / PM / HR ── + { keyword: "writer", domain: "writing" }, + { keyword: "copywriter", domain: "writing" }, + { keyword: "marketer", domain: "writing" }, + { keyword: "marketing", domain: "writing" }, + { keyword: "content", domain: "writing" }, + { keyword: "editor", domain: "writing" }, + { keyword: "pm", domain: "writing" }, + { keyword: "designer", domain: "writing" }, + { keyword: "hr", domain: "writing" }, + { keyword: "撰稿", domain: "writing" }, + { keyword: "文案", domain: "writing" }, + { keyword: "市场", domain: "writing" }, + { keyword: "营销", domain: "writing" }, + { keyword: "内容", domain: "writing" }, + { keyword: "编辑", domain: "writing" }, + { keyword: "设计师", domain: "writing" }, + { keyword: "产品经理", domain: "writing" }, + { keyword: "运营", domain: "writing" }, + { keyword: "人力资源", domain: "writing" }, + { keyword: "人事", domain: "writing" }, +]; + +/** + * Keyword cues that hard-pin a role to the trivial tier regardless of the + * role-level enum. A data-entry clerk whose `roleLevel` is `ic` runs on a + * trivial-tier model even if the free-text title is long; an "intern + * director" (yes, these exist) gets reviewed by their title before their + * level. Mixed English + Chinese cues. + */ +const TRIVIAL_KEYWORDS = [ + "intern", + "assistant", + "clerk", + "entry", + "junior", + "associate", + "实习", + "助理", + "初级", + "见习", + "文员", +] as const; + +/** + * Map a role string + optional title to (domain, requiredTier). Both the + * domain and the tier are conservative defaults — the picker will still + * fall back to the CEO model if no enabled candidate clears the bar, so + * under-classifying here is always safe. + * + * The CEO / c_suite cases are handled by the caller (the dispatcher has + * access to `agent.roleLevel` and knows to pin CEO to the user-chosen + * model). This function treats every input as a subordinate. + */ +export function inferTaskProfile( + role: string, + title: string | null | undefined, + roleLevel?: AgentRoleLevel | null, +): TaskProfile { + const loweredText = `${role} ${title ?? ""}`.toLowerCase(); + const tokens = new Set(tokenize(loweredText)); + + // 1. Domain by keyword — first match wins. + let domain: TaskDomain = "writing"; + for (const entry of DOMAIN_KEYWORDS) { + if (matchesKeyword(loweredText, tokens, entry.keyword)) { + domain = entry.domain; + break; + } + } + + // 2. Tier is pinned trivial by explicit cues; otherwise rides role level. + const trivialOverride = TRIVIAL_KEYWORDS.some((kw) => matchesKeyword(loweredText, tokens, kw)); + let tier: TaskTier; + if (trivialOverride) { + tier = "trivial"; + } else { + switch (roleLevel) { + case "c_suite": + case "vp": + case "director": + tier = "hard"; + break; + case "manager": + case "lead": + tier = "standard"; + break; + case "ic": + case null: + case undefined: + default: + tier = "standard"; + break; + } + } + + return { domain, requiredTier: tier }; +} diff --git a/src/main/agent-runtime/agent-event-projection.ts b/src/main/agent-runtime/agent-event-projection.ts new file mode 100644 index 00000000..1740581c --- /dev/null +++ b/src/main/agent-runtime/agent-event-projection.ts @@ -0,0 +1,127 @@ +/** + * Pure projection helpers for pi AgentEvent → human-readable console log. + * + * Extracted from runtime-bridge.ts to keep the bridge under the 400-line + * cap. These functions never touch DB, IPC, or filesystem — they are + * deterministic text transforms and must stay that way. New event + * types are added here; the bridge pipes the result into its run-log + * stream. + * + * Reference: pi-mono-reference/packages/agent/src/types.ts `AgentEvent` + * union is the canonical shape we project. + */ + +export type AgentEventLogChunk = { stream: "stdout" | "stderr"; chunk: string }; + +/** + * Extract plain text from a pi AgentMessage's content array. User + * messages carry `content: [{ type: "text", text: "..." }]`; other + * content types (image, tool_result, thinking) are filtered out. + */ +export function extractMessageText(message: { content?: unknown }): string { + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (block && typeof block === "object" && (block as { type?: string }).type === "text") { + const text = (block as { text?: string }).text; + if (typeof text === "string") parts.push(text); + } + } + return parts.join(""); +} + +/** + * Compact JSON preview used in tool-execution log lines. Falls back to a + * sentinel when the value cannot be serialised (circular refs etc.). + */ +export function summariseJson(value: unknown, maxLength: number): string { + if (value === undefined || value === null) return ""; + try { + const json = typeof value === "string" ? value : JSON.stringify(value); + if (json.length <= maxLength) return json; + return `${json.slice(0, maxLength - 3)}...`; + } catch { + return ""; + } +} + +/** + * Best-effort textual rendering of a pi message's content for thread + * rows. Handles text, thinking, and toolCall blocks; returns an empty + * string for purely structural payloads so the thread view does not + * show noise. + */ +export function extractTextBody(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const obj = block as { type?: string; text?: string; thinking?: string; name?: string }; + if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text); + else if (obj.type === "thinking" && typeof obj.thinking === "string") parts.push(`[thinking] ${obj.thinking}`); + else if (obj.type === "toolCall" && typeof obj.name === "string") parts.push(`[tool:${obj.name}]`); + } + return parts.join("\n").trim(); +} + +/** + * Project a pi AgentEvent to a single console log chunk. Returns null + * for events that should not appear in the log (e.g., streaming + * deltas whose content is already rendered upstream). + */ +export function projectAgentEventToLogChunk( + event: { type: string } & Record, +): AgentEventLogChunk | null { + switch (event.type) { + case "agent_start": + return { stream: "stdout", chunk: "\n[agent:start]\n" }; + case "turn_start": + return { stream: "stdout", chunk: "\n" }; + case "turn_end": + return { stream: "stdout", chunk: "\n" }; + case "message_start": { + const message = event.message as { role?: string; content?: unknown } | undefined; + if (message?.role === "user") { + const body = extractMessageText(message); + if (!body) return null; + return { stream: "stdout", chunk: `[user] ${body}\n` }; + } + return null; + } + case "message_update": { + const inner = event.assistantMessageEvent as + | ({ type?: string; delta?: string; content?: string } & Record) + | undefined; + if (!inner) return null; + if (inner.type === "text_delta" && typeof inner.delta === "string" && inner.delta.length > 0) { + return { stream: "stdout", chunk: inner.delta }; + } + if (inner.type === "thinking_delta" && typeof inner.delta === "string" && inner.delta.length > 0) { + return { stream: "stdout", chunk: inner.delta }; + } + return null; + } + case "message_end": { + return { stream: "stdout", chunk: "\n" }; + } + case "tool_execution_start": { + const toolName = typeof event.toolName === "string" ? event.toolName : "tool"; + const argsPreview = summariseJson(event.args, 200); + return { stream: "stdout", chunk: `\n[tool:${toolName}] ${argsPreview}\n` }; + } + case "tool_execution_end": { + const toolName = typeof event.toolName === "string" ? event.toolName : "tool"; + const isError = event.isError === true; + const resultPreview = summariseJson(event.result, 300); + const tag = isError ? "tool:error" : "tool:done"; + return { stream: isError ? "stderr" : "stdout", chunk: `[${tag}:${toolName}] ${resultPreview}\n` }; + } + case "agent_end": + return { stream: "stdout", chunk: "\n[agent:end]\n" }; + default: + return null; + } +} diff --git a/src/main/agent-runtime/artifact-projector.ts b/src/main/agent-runtime/artifact-projector.ts new file mode 100644 index 00000000..01d3af40 --- /dev/null +++ b/src/main/agent-runtime/artifact-projector.ts @@ -0,0 +1,157 @@ +import { isAbsolute, relative, resolve } from "node:path"; +import type { AppDatabase } from "@main/database"; + +/** + * Auto-registration of filesystem artifacts produced by pi's write / edit + * tools. Every successful tool call that mutates a workspace file is + * projected into a `work_products` row so the desktop UI sees what the + * agent actually produced — the old behavior required the agent to call + * `POST /api/documents` manually, which was routinely forgotten and left + * dozens of files on disk invisible to users. + * + * Registration is idempotent: the natural identity of an artifact is + * `(companyId, provider, externalId)` where externalId is the workspace- + * relative path. Re-writing a file updates the existing row instead of + * creating duplicates. + * + * This module is pure data-structure plumbing — it holds no state and + * does no I/O beyond the single SQLite upsert. The RuntimeBridge owns + * the call site. + */ + +/** Paths we never register — tool output that is never a deliverable. */ +const IGNORED_PATH_SEGMENTS: readonly string[] = [ + "/.git/", + "/node_modules/", + "/.next/", + "/dist/", + "/build/", + "/out/", + "/.cache/", + "/.pnpm-store/", + "/.DS_Store", +]; +const IGNORED_EXACT_NAMES: ReadonlySet = new Set([ + ".DS_Store", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + ".gitignore", +]); +/** Extensions we never register as work products (binaries, build output). */ +const IGNORED_EXTENSIONS: ReadonlySet = new Set([ + ".lock", + ".log", + ".map", + ".min.js", + ".min.css", +]); + +export type ArtifactProjectorToolEvent = { + toolName: string; + args: unknown; + result: unknown; + isError: boolean; +}; + +export type ArtifactProjectorRunContext = { + runId: string; + agentId: string; + companyId: string; + taskId: string | null; + workspacePath: string | null; +}; + +type WriteArgs = { path: unknown; content?: unknown }; +type EditArgs = { path: unknown }; + +/** Normalises pi tool-call args to the file path the agent touched. */ +function extractToolPath(toolName: string, args: unknown): string | null { + if (!args || typeof args !== "object") return null; + if (toolName === "write") { + const p = (args as WriteArgs).path; + return typeof p === "string" && p.length > 0 ? p : null; + } + if (toolName === "edit") { + const p = (args as EditArgs).path; + return typeof p === "string" && p.length > 0 ? p : null; + } + return null; +} + +/** Returns true when the path is inside the workspace and not on the ignore list. */ +function isRegistrablePath(absPath: string, workspacePath: string): boolean { + const rel = relative(workspacePath, absPath); + if (rel.startsWith("..") || isAbsolute(rel)) return false; + const probePath = `/${rel}/`; + if (IGNORED_PATH_SEGMENTS.some((seg) => probePath.includes(seg))) return false; + const lastSlash = rel.lastIndexOf("/"); + const basename = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel; + if (IGNORED_EXACT_NAMES.has(basename)) return false; + const dot = basename.lastIndexOf("."); + if (dot > 0) { + const ext = basename.slice(dot).toLowerCase(); + if (IGNORED_EXTENSIONS.has(ext)) return false; + // Catch compound extensions like ".min.js" by checking the last two dots. + const prevDot = basename.lastIndexOf(".", dot - 1); + if (prevDot > 0) { + const compound = basename.slice(prevDot).toLowerCase(); + if (IGNORED_EXTENSIONS.has(compound)) return false; + } + } + return true; +} + +export type ProjectionOutcome = + | { kind: "skipped"; reason: string } + | { kind: "inserted"; workProductId: string; relativePath: string } + | { kind: "updated"; workProductId: string; relativePath: string }; + +/** + * Upsert a work_products row for a successful write/edit tool call. + * Returns an outcome describing whether the artifact was inserted, + * updated, or skipped (with reason). Returns "skipped" for every + * non-applicable case so the caller can log a single stream of events. + */ +export function projectToolExecutionToWorkProduct( + db: AppDatabase, + ctx: ArtifactProjectorRunContext, + event: ArtifactProjectorToolEvent, +): ProjectionOutcome { + if (event.isError) return { kind: "skipped", reason: "tool_error" }; + if (event.toolName !== "write" && event.toolName !== "edit") { + return { kind: "skipped", reason: "unsupported_tool" }; + } + if (!ctx.workspacePath) return { kind: "skipped", reason: "no_workspace" }; + + const rawPath = extractToolPath(event.toolName, event.args); + if (!rawPath) return { kind: "skipped", reason: "missing_path" }; + + const absPath = isAbsolute(rawPath) ? rawPath : resolve(ctx.workspacePath, rawPath); + if (!isRegistrablePath(absPath, ctx.workspacePath)) { + return { kind: "skipped", reason: "path_ignored" }; + } + + const relativePath = relative(ctx.workspacePath, absPath); + const lastSlash = relativePath.lastIndexOf("/"); + const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath; + + const provider = "filesystem"; + const existing = db.findWorkProductByExternalId(ctx.companyId, provider, relativePath); + if (existing) { + db.updateWorkProduct(existing.id, ctx.companyId, { title }); + return { kind: "updated", workProductId: existing.id, relativePath }; + } + + const workProductId = db.createWorkProduct({ + companyId: ctx.companyId, + taskId: ctx.taskId, + type: "file", + provider, + externalId: relativePath, + title, + url: null, + createdByRunId: ctx.runId, + }); + return { kind: "inserted", workProductId, relativePath }; +} diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index 10a65af2..532deff2 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -7,103 +7,8 @@ import { CostWriter } from "./cost-writer"; import type { CredentialResolver } from "@main/credentials"; import { getConnectorDefinition } from "@main/connectors"; import { projectInboxMessageToPi } from "./inbox-projection"; - -/** - * Projects a pi AgentEvent to a human-readable log line for the Console. - * See pi-mono-reference/packages/agent/src/types.ts `AgentEvent` for the - * canonical union shape; this projection handles every type that carries - * user-visible activity. - */ -function projectAgentEventToLogChunk( - event: { type: string } & Record, -): { stream: "stdout" | "stderr"; chunk: string } | null { - switch (event.type) { - case "agent_start": - return { stream: "stdout", chunk: "\n[agent:start]\n" }; - case "turn_start": - return { stream: "stdout", chunk: "\n" }; - case "turn_end": - return { stream: "stdout", chunk: "\n" }; - case "message_start": { - const message = event.message as { role?: string; content?: unknown } | undefined; - if (message?.role === "user") { - const body = extractMessageText(message); - if (!body) return null; - return { stream: "stdout", chunk: `[user] ${body}\n` }; - } - return null; - } - case "message_update": { - // Streaming assistant deltas — `assistantMessageEvent.type` carries - // the intra-turn stream protocol from pi-ai (text_delta / - // thinking_delta / toolcall_start / ...). We surface text and - // thinking deltas verbatim so the Console shows live output. - const inner = event.assistantMessageEvent as - | ({ type?: string; delta?: string; content?: string } & Record) - | undefined; - if (!inner) return null; - if (inner.type === "text_delta" && typeof inner.delta === "string" && inner.delta.length > 0) { - return { stream: "stdout", chunk: inner.delta }; - } - if (inner.type === "thinking_delta" && typeof inner.delta === "string" && inner.delta.length > 0) { - return { stream: "stdout", chunk: inner.delta }; - } - return null; - } - case "message_end": { - // The final assistant message is already fully rendered via - // preceding message_update deltas; emit a trailing newline so the - // next turn starts on its own line. - return { stream: "stdout", chunk: "\n" }; - } - case "tool_execution_start": { - const toolName = typeof event.toolName === "string" ? event.toolName : "tool"; - const argsPreview = summariseJson(event.args, 200); - return { stream: "stdout", chunk: `\n[tool:${toolName}] ${argsPreview}\n` }; - } - case "tool_execution_end": { - const toolName = typeof event.toolName === "string" ? event.toolName : "tool"; - const isError = event.isError === true; - const resultPreview = summariseJson(event.result, 300); - const tag = isError ? "tool:error" : "tool:done"; - return { stream: isError ? "stderr" : "stdout", chunk: `[${tag}:${toolName}] ${resultPreview}\n` }; - } - case "agent_end": - return { stream: "stdout", chunk: "\n[agent:end]\n" }; - default: - return null; - } -} - -/** - * Pulls plain text out of a pi AgentMessage's content array. User messages - * carry `content: [{ type: "text", text: "..." }]` per pi's AgentMessage - * schema; other content types (image, tool_result, etc.) are ignored here. - */ -function extractMessageText(message: { content?: unknown }): string { - const content = message.content; - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - const parts: string[] = []; - for (const block of content) { - if (block && typeof block === "object" && (block as { type?: string }).type === "text") { - const text = (block as { text?: string }).text; - if (typeof text === "string") parts.push(text); - } - } - return parts.join(""); -} - -function summariseJson(value: unknown, maxLength: number): string { - if (value === undefined || value === null) return ""; - try { - const json = typeof value === "string" ? value : JSON.stringify(value); - if (json.length <= maxLength) return json; - return `${json.slice(0, maxLength - 3)}...`; - } catch { - return ""; - } -} +import { projectToolExecutionToWorkProduct } from "./artifact-projector"; +import { projectAgentEventToLogChunk, extractTextBody } from "./agent-event-projection"; export interface RuntimeBridgeDeps { db: AppDatabase; @@ -124,6 +29,12 @@ interface ActiveRunContext { connectorId: string; modelId: string; taskSessionId: string; + /** + * Workspace root handed to pi's coding tools as cwd. Needed by the + * artifact projector to resolve tool-reported paths (which may be + * relative) and to reject writes that escape the workspace. + */ + workspacePath: string | null; } /** @@ -217,6 +128,10 @@ export class RuntimeBridge { }); } + if (envelope.event.type === "tool_execution_end") { + this.projectToolArtifact(envelope); + } + if (envelope.event.type === "message_end") { const ctx = this.activeRuns.get(envelope.runId); const messageRaw = envelope.event.message as @@ -248,6 +163,49 @@ export class RuntimeBridge { } } + /** + * Project a pi `tool_execution_end` event into the `work_products` + * table when the tool was `write` or `edit` and succeeded. This + * replaces the earlier behaviour where the agent had to remember to + * `POST /api/documents` after writing a file; agents forgot routinely, + * so the desktop UI showed an empty artifact list while the workspace + * filesystem filled up. The projector is idempotent and silent — all + * skip reasons are traced to the warn channel so operators can audit + * why a given write did not register. + */ + private projectToolArtifact(envelope: { + runId: string; + event: { type: string } & Record; + }): void { + const ctx = this.activeRuns.get(envelope.runId); + if (!ctx) return; + const e = envelope.event; + const toolName = typeof e.toolName === "string" ? e.toolName : null; + if (!toolName) return; + try { + projectToolExecutionToWorkProduct( + this.deps.db, + { + runId: ctx.runId, + agentId: ctx.agentId, + companyId: ctx.companyId, + taskId: ctx.taskId, + workspacePath: ctx.workspacePath, + }, + { + toolName, + args: e.args, + result: e.result, + isError: e.isError === true, + }, + ); + } catch (error) { + this.deps.logger.warn( + `[runtime-bridge] artifact projection failed for run ${envelope.runId} tool ${toolName}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + /** * Project the pi `message_end` event into the task thread as a * `conversation_events` row so renderer thread views stay live. @@ -312,6 +270,7 @@ export class RuntimeBridge { companyId: string; taskId: string | null; modelId: string; + workspacePath: string | null; }; this.registerActiveRun({ runId: request.runId, @@ -321,6 +280,7 @@ export class RuntimeBridge { connectorId: payload.connectorId, modelId: payload.modelId, taskSessionId: payload.taskSessionId, + workspacePath: payload.workspacePath, }); const definition = getConnectorDefinition(payload.connectorId); const resolved = await this.deps.credentialResolver.resolve(payload.connectorId); @@ -364,17 +324,3 @@ export class RuntimeBridge { } } -/** Best-effort textual rendering of a pi message's content for thread rows. */ -function extractTextBody(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - const parts: string[] = []; - for (const block of content) { - if (!block || typeof block !== "object") continue; - const obj = block as { type?: string; text?: string; thinking?: string; name?: string }; - if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text); - else if (obj.type === "thinking" && typeof obj.thinking === "string") parts.push(`[thinking] ${obj.thinking}`); - else if (obj.type === "toolCall" && typeof obj.name === "string") parts.push(`[tool:${obj.name}]`); - } - return parts.join("\n").trim(); -} diff --git a/src/main/database-base.ts b/src/main/database-base.ts index f7cf06a3..323a86ed 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -24,6 +24,8 @@ import { buildWakeupMethods } from "./database/wakeup"; import { buildBackupMethods } from "./database/backup"; import { buildSessionEntryMethods } from "./database/session-entries"; import { buildCredentialMethods } from "./database/credentials"; +import { buildModelCapabilityMethods } from "./database/model-capabilities-crud"; +import { MODEL_CAPABILITY_SEED } from "./model-capability-seed"; import { initializeDatabase } from "./database/schema"; import { SecretVault } from "./vault"; import type { AgentStatus, ConnectorId, ProfileSnapshot, RunStatus } from "@shared/types"; @@ -68,6 +70,7 @@ export class AppDatabaseBase { public readonly backupMod: ReturnType; public readonly sessionEntries: ReturnType; public readonly credentialsTable: ReturnType; + public readonly modelCapabilities: ReturnType; constructor(rootDir: string, options: { isPackaged?: boolean } = {}) { this.profileDir = rootDir; @@ -141,6 +144,7 @@ export class AppDatabaseBase { this.wakeup = buildWakeupMethods(getDb, ra, tx); this.sessionEntries = buildSessionEntryMethods(getDb, ra, tx); this.credentialsTable = buildCredentialMethods(getDb, ra, tx); + this.modelCapabilities = buildModelCapabilityMethods(getDb, ra, tx); this.backupMod = buildBackupMethods({ getDb, setDb: (db) => { this.db = db; }, openConnection: () => this.openConnection(), @@ -159,11 +163,94 @@ export class AppDatabaseBase { const tx = (op: () => T) => this.transaction(op); initializeDatabase(this.db, ra, tx); this.connectorsCrud.seedConnectors(); + this.modelCapabilities.seedModelCapabilities(MODEL_CAPABILITY_SEED); this._lastInterruptedCount = this.interruptStaleRuns(); + this._lastOrphanResetCount = this.reconcileOrphanedTasks(); + this._lastUnreviewableResetCount = this.reconcileUnreviewableTasks(); this.settings.purgeExpiredRunLogs(); this.wakeup.purgeExpiredWakeupRequests(); } + /** + * Reset tasks stuck at `in_progress` with no live run. + * + * Invariant: `task.status = 'in_progress'` ⇒ there exists a run `r` + * where `r.status IN ('queued', 'running')` referenced by this task + * (via checkout_run_id or active_run_id). + * + * Whenever the invariant breaks — because a dispatch failed, a worker + * crashed, or a pre-fix finishRun cleared the run refs but not the + * status — the task becomes un-reclaimable: `checkoutTask`'s guard + * requires `status IN ('todo','backlog')`, and heartbeat loops forever + * logging "lost checkout race". One query at boot drains every such + * orphan, regardless of which of the two reference columns happens to + * be null. The runtime `finishRun` path enforces the same invariant + * going forward, so this should stabilise at zero rows changed. + */ + protected reconcileOrphanedTasks(): number { + const result = this.db + .prepare( + `UPDATE tasks + SET status = 'todo', + checkout_run_id = NULL, + execution_locked_at = NULL, + active_run_id = NULL, + updated_at = ? + WHERE status = 'in_progress' + AND NOT EXISTS ( + SELECT 1 FROM runs r + WHERE (r.id = tasks.checkout_run_id OR r.id = tasks.active_run_id) + AND r.status IN ('queued', 'running') + )`, + ) + .run(new Date().toISOString()); + const changes = Number(result.changes); + if (changes > 0) { + + console.info(`[init] Reconciled ${changes} orphaned in_progress task(s) → todo.`); + } + return changes; + } + + /** + * Reset tasks stuck at `in_review` with no reachable reviewer. + * + * A task enters `in_review` when an assignee submits work for approval. + * The reviewer is the assignee's manager (walk up via `reports_to`), + * or the human operator when the task is flagged + * `requires_user_review = 1`. When the assignee is a top-level agent + * (CEO/board, `reports_to IS NULL`) and the task does not require + * user review, *no one* can review it — the task becomes a dead + * letter, and the CEO heartbeat falls into a loop re-proposing its + * `approve_ceo_strategy` without ever recovering. + * + * This observed pattern cost us a full debugging session: CEO + * self-assigned a large task, moved it to `in_review`, then spent + * every heartbeat cycle re-emitting the same approval. Demoting the + * task to `todo` breaks the loop — the CEO sees un-reviewable work + * and correctly falls through to the hire path instead. + */ + protected reconcileUnreviewableTasks(): number { + const result = this.db + .prepare( + `UPDATE tasks + SET status = 'todo', updated_at = ? + WHERE status = 'in_review' + AND requires_user_review = 0 + AND assignee_agent_id IS NOT NULL + AND assignee_agent_id IN ( + SELECT id FROM agents WHERE reports_to IS NULL + )`, + ) + .run(new Date().toISOString()); + const changes = Number(result.changes); + if (changes > 0) { + + console.info(`[init] Reconciled ${changes} un-reviewable in_review task(s) → todo.`); + } + return changes; + } + close() { this.db.close(); } // ─── Settings ────────────────────────────────────────────────────────────── @@ -196,7 +283,6 @@ export class AppDatabaseBase { getCompanyExecutionMode(companyId: string) { return this.company.getCompanyExecutionMode(companyId); } getCompanyApprovalSettings(companyId: string) { return this.company.getCompanyApprovalSettings(companyId); } listActiveCompanyIds() { return this.company.listActiveCompanyIds(); } - getSidebarBadges(companyId: string) { return this.company.getSidebarBadges(companyId); } getInbox(companyId: string) { return this.company.getInbox(companyId); } getCompanyMetrics(companyId: string, days: number = 30) { return this.company.getCompanyMetrics(companyId, days); } getStandupReport(companyId: string) { return this.company.getStandupReport(companyId); } @@ -211,7 +297,7 @@ export class AppDatabaseBase { // ─── Agents ──────────────────────────────────────────────────────────────── - saveAgent(input: { id?: string | null; companyId: string; name: string; role: string; title: string; department?: string | null; status: AgentStatus; reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; model?: string | null; metadataJson?: string; capabilities: string; budgetMonthlyUsd: number }) { return this.agents.saveAgent(input); } + saveAgent(input: { id?: string | null; companyId: string; name: string; role: string; title: string; department?: string | null; status: AgentStatus; reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; model?: string | null; metadataJson?: string; capabilities: string; budgetMonthlyUsd: number; roleLevel?: import("@shared/types").AgentRoleLevel; modelStrategy?: import("@shared/types").AgentModelStrategy }) { return this.agents.saveAgent(input); } deleteAgent(id: string, companyId: string) { return this.agents.deleteAgent(id, companyId); } getAgent(id: string) { return this.entityCrud.getAgent(id); } findAgent(id: string) { return this.entityCrud.findAgent(id); } @@ -257,6 +343,7 @@ export class AppDatabaseBase { updateTaskSession(taskId: string, sessionJson: Record | null, sessionDisplayId: string | null) { return this.tasks.updateTaskSession(taskId, sessionJson, sessionDisplayId); } getTaskSession(taskId: string) { return this.tasks.getTaskSession(taskId); } getActiveTaskSession(taskId: string) { return this.tasks.getActiveTaskSession(taskId); } + getSessionById(sessionId: string) { return this.tasks.getSessionById(sessionId); } incrementSessionUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string) { return this.tasks.incrementSessionUsage(sessionId, inputTokens, outputTokens, runId); } archiveSession(sessionId: string, reason: string, handoffMarkdown: string) { return this.tasks.archiveSession(sessionId, reason, handoffMarkdown); } createNewSession(input: { taskId: string; companyId: string; agentId: string; adapterType: string; sessionParamsJson: string | null; sessionDisplayId: string | null }) { return this.tasks.createNewSession(input); } @@ -353,7 +440,11 @@ export class AppDatabaseBase { // ─── Internal infrastructure ─────────────────────────────────────────────── protected _lastInterruptedCount = 0; + protected _lastOrphanResetCount = 0; + protected _lastUnreviewableResetCount = 0; get lastInterruptedCount(): number { return this._lastInterruptedCount; } + get lastOrphanResetCount(): number { return this._lastOrphanResetCount; } + get lastUnreviewableResetCount(): number { return this._lastUnreviewableResetCount; } interruptStaleRuns(): number { const ts = nowIso(); diff --git a/src/main/database.ts b/src/main/database.ts index 968c599d..5fb54410 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -94,9 +94,10 @@ export class AppDatabase extends AppDatabaseBase { createDocumentRevision(input: { documentId: string; companyId: string; body: string; changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.createDocumentRevision(input); } getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); } listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); } - createWorkProduct(input: { companyId: string; taskId: string; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); } + createWorkProduct(input: { companyId: string; taskId: string | null; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); } updateWorkProduct(id: string, companyId: string, patch: Parameters["updateWorkProduct"]>[2]) { return this.documentsMod.updateWorkProduct(id, companyId, patch); } listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); } + findWorkProductByExternalId(companyId: string, provider: string, externalId: string) { return this.documentsMod.findWorkProductByExternalId(companyId, provider, externalId); } deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, companyId); } linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); } unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); } diff --git a/src/main/database/agents-crud.ts b/src/main/database/agents-crud.ts index 984a2e21..5f177c30 100644 --- a/src/main/database/agents-crud.ts +++ b/src/main/database/agents-crud.ts @@ -1,6 +1,6 @@ import type { DatabaseSync, SQLInputValue } from "node:sqlite"; import log from "electron-log/main.js"; -import type { AgentRecord, AgentRoleLevel, ConnectorId } from "@shared/types"; +import type { AgentModelStrategy, AgentRecord, AgentRoleLevel, ConnectorId } from "@shared/types"; import { type ReadAll, type Transaction, @@ -48,6 +48,13 @@ export function buildAgentCrudMethods( budgetMonthlyUsd: number; /** Optional explicit seniority; inferred from role/title when omitted. */ roleLevel?: AgentRoleLevel; + /** + * Model routing strategy (v39+). `explicit` pins the agent to + * `connectorId + model`; `auto` lets the dispatcher pick at run + * time. Omitting this leaves the existing column untouched on + * update, and uses the DB default ('explicit') on insert. + */ + modelStrategy?: AgentModelStrategy; }): string { const id = ensureId(input.id ?? null); const ts = nowIso(); @@ -64,12 +71,17 @@ export function buildAgentCrudMethods( // Snapshot before-config for revision tracking const beforeAgent = getDb() .prepare( - "SELECT name, role, title, department, capabilities, connector_id, model, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?", + "SELECT name, role, title, department, capabilities, connector_id, model, model_strategy, role_level, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?", ) .get(id, input.companyId) as Record | undefined; + // Preserve existing modelStrategy and roleLevel when the caller omits + // them, so non-related updates (e.g. updateAgentWorkspaceBinding) do + // not accidentally flip routing or seniority. + const effectiveStrategy = input.modelStrategy ?? (beforeAgent?.model_strategy as string | undefined) ?? "explicit"; + const effectiveRoleLevel = input.roleLevel ?? (beforeAgent?.role_level as string | undefined) ?? "ic"; getDb() .prepare( - "update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?", + "update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, model_strategy = ?, role_level = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?", ) .run( input.name, @@ -81,6 +93,8 @@ export function buildAgentCrudMethods( input.connectorId, normalizeOptionalId(input.workspaceId), normalizeOptionalId(input.model), + effectiveStrategy, + effectiveRoleLevel, metadataJson, input.capabilities, input.budgetMonthlyUsd, @@ -132,9 +146,11 @@ export function buildAgentCrudMethods( } } } else { + const insertStrategy = input.modelStrategy ?? "explicit"; + const insertRoleLevel = input.roleLevel ?? "ic"; getDb() .prepare( - "insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", + "insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, model_strategy, role_level, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", ) .run( id, @@ -148,6 +164,8 @@ export function buildAgentCrudMethods( input.connectorId, normalizeOptionalId(input.workspaceId), normalizeOptionalId(input.model), + insertStrategy, + insertRoleLevel, input.capabilities, input.budgetMonthlyUsd, metadataJson, diff --git a/src/main/database/agents-hierarchy.ts b/src/main/database/agents-hierarchy.ts index fe6ccda7..a18ceb04 100644 --- a/src/main/database/agents-hierarchy.ts +++ b/src/main/database/agents-hierarchy.ts @@ -62,7 +62,7 @@ export function buildAgentHierarchyMethods( `WITH RECURSIVE chain AS ( SELECT id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, - model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, + model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt, @@ -71,7 +71,7 @@ export function buildAgentHierarchyMethods( UNION ALL SELECT a.id, a.company_id, a.name, a.role, a.title, a.department, a.status, a.reports_to, a.connector_id, a.workspace_id, - a.model, a.capabilities, a.role_level, a.budget_monthly_usd, + a.model, a.capabilities, a.role_level, a.model_strategy, a.budget_monthly_usd, a.spent_monthly_usd, a.metadata_json, a.heartbeat_enabled, a.heartbeat_interval_sec, a.last_heartbeat_at, a.session_id, a.created_at, a.updated_at, @@ -91,7 +91,7 @@ export function buildAgentHierarchyMethods( getDirectReports(agentId: string, companyId: string): AgentRecord[] { return readAll( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?", + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?", companyId, agentId, ).map((record) => ({ @@ -121,7 +121,7 @@ export function buildAgentHierarchyMethods( */ listAgentsForApi(companyId: string): AgentApiRow[] { return readAll( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc", + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc", companyId, ).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) })) as AgentApiRow[]; }, @@ -200,7 +200,7 @@ export function buildAgentHierarchyMethods( listHeartbeatEligibleAgents(companyId: string): AgentRecord[] { return readAll( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')", + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')", companyId, ).map((record) => ({ ...record, diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index 9f737f4a..1574a72e 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -185,7 +185,7 @@ export function buildDocumentMethods( // ── Work Products ── createWorkProduct(input: { - companyId: string; taskId: string; projectId?: string | null; + companyId: string; taskId: string | null; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; @@ -193,7 +193,7 @@ export function buildDocumentMethods( }): string { const id = randomUUID(); const ts = nowIso(); - if (input.isPrimary) { + if (input.isPrimary && input.taskId) { getDb().prepare( "UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?", ).run(ts, input.companyId, input.taskId, input.type); @@ -202,7 +202,7 @@ export function buildDocumentMethods( `INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( - id, input.companyId, input.projectId ?? null, input.taskId, + id, input.companyId, input.projectId ?? null, input.taskId ?? null, input.type, input.provider, input.externalId ?? null, input.title, input.url ?? null, input.status ?? "active", input.reviewState ?? "none", input.isPrimary ? 1 : 0, @@ -212,6 +212,29 @@ export function buildDocumentMethods( return id; }, + /** + * Look up a work product by its natural identity — the tuple + * (companyId, provider, externalId) uniquely names a registered + * artifact. Returns null when not found. Used by the artifact + * projector to make tool-output registration idempotent: the first + * write inserts, subsequent writes (same path, same provider) go + * through `updateWorkProduct` instead of spamming duplicates. + */ + findWorkProductByExternalId( + companyId: string, + provider: string, + externalId: string, + ): { id: string; taskId: string | null } | null { + const row = getDb() + .prepare( + "SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND provider = ? AND external_id = ? LIMIT 1", + ) + .get(companyId, provider, externalId) as + | { id: string; taskId: string | null } + | undefined; + return row ?? null; + }, + updateWorkProduct( id: string, companyId: string, diff --git a/src/main/database/entity-crud.ts b/src/main/database/entity-crud.ts index c756db55..7725939c 100644 --- a/src/main/database/entity-crud.ts +++ b/src/main/database/entity-crud.ts @@ -63,7 +63,7 @@ export function buildEntityCrudMethods( getAgent(id: string): AgentRecord { const row = getDb() .prepare( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?", + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?", ) .get(id) as AgentRecord | undefined; if (!row) throw new Error(`Agent not found: ${id}`); @@ -74,7 +74,7 @@ export function buildEntityCrudMethods( findAgent(id: string): AgentRecord | null { const row = getDb() .prepare( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?", + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?", ) .get(id) as AgentRecord | undefined; if (!row) return null; @@ -251,6 +251,7 @@ export function buildEntityCrudMethods( reportsTo: string | null; connectorId: ConnectorId; workspaceId: string | null; model: string | null; metadataJson: string; capabilities: string; budgetMonthlyUsd: number; + modelStrategy?: "explicit" | "auto"; }) => string; requestApproval: (input: { companyId: string; requestedByAgentId: string | null; @@ -267,6 +268,11 @@ export function buildEntityCrudMethods( connectorId: input.connectorId, workspaceId: input.workspaceId ?? null, model: input.model ?? null, metadataJson: input.metadataJson ?? "{}", capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd, + // Agents hired by the CEO (or any other agent) default to `auto`: + // the dispatcher will pick the cheapest enabled model that + // clears their inferred task profile. User-driven form saves + // flip this to 'explicit' at the IPC handler. + modelStrategy: "auto", }); const approvalId = selfDeps.requestApproval({ companyId: input.companyId, diff --git a/src/main/database/migrations/v38-v39.ts b/src/main/database/migrations/v38-v39.ts new file mode 100644 index 00000000..2cf654e1 --- /dev/null +++ b/src/main/database/migrations/v38-v39.ts @@ -0,0 +1,75 @@ +import log from "electron-log/main.js"; +import type { Migration, MigrationDeps } from "./types"; +import { columnExists, tableExists } from "./types"; + +export function createMigrationsV38V39(deps: MigrationDeps): Migration[] { + const { db } = deps; + return [ + { + // Model capability table. Maps (provider, model_id) to 1..5 scores across + // three domains (reasoning / coding / writing), a tier enum used as a + // hard lower bound for task-profile matching, and a cost-per-Mtok-output + // figure used to pick the cheapest qualifying model. + // + // Scores, tier, and cost are data — editable at runtime via IPC. Seed + // rows are inserted by the main process on startup when the table is + // empty so a fresh install boots with a sensible matrix covering the + // providers in the pi-ai catalog and the curated Fireworks list. + version: 38, + name: "create_model_capabilities", + fn: () => { + if (tableExists(db, "model_capabilities")) { + log.info("[migration v38] model_capabilities already exists; skipping"); + return; + } + db.exec( + `CREATE TABLE model_capabilities ( + provider TEXT NOT NULL, + model_id TEXT NOT NULL, + reasoning INTEGER NOT NULL, + coding INTEGER NOT NULL, + writing INTEGER NOT NULL, + tier TEXT NOT NULL, + cost_per_mtok_out REAL NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (provider, model_id) + )`, + ); + db.exec("CREATE INDEX idx_model_capabilities_tier ON model_capabilities(tier)"); + log.info("[migration v38] created model_capabilities table"); + }, + }, + { + // agents.model_strategy drives run-time model dispatch. + // + // 'explicit' — use agent.connector_id and agent.model verbatim + // (the pre-v39 behavior — kept for existing rows so there + // are no regressions on upgrade). + // 'auto' — ignore agent.connector_id/model; at run dispatch time + // pick the cheapest model whose capability vector meets + // the agent's inferred task profile. + // + // New agents default to 'auto' at the application layer. Existing rows + // on upgrade are forced to 'explicit' because the user previously picked + // their model manually and we must not silently re-route them. + version: 39, + name: "add_agents_model_strategy", + fn: () => { + if (!tableExists(db, "agents")) { + throw new Error("agents table missing; refusing to migrate v39"); + } + if (columnExists(db, "agents", "model_strategy")) { + log.info("[migration v39] agents.model_strategy already exists; skipping"); + return; + } + // Default at schema level = 'explicit' so existing rows keep their + // hand-picked model. Application code uses 'auto' as the default for + // freshly created agents. + db.exec( + "ALTER TABLE agents ADD COLUMN model_strategy TEXT NOT NULL DEFAULT 'explicit'", + ); + log.info("[migration v39] added agents.model_strategy column"); + }, + }, + ]; +} diff --git a/src/main/database/migrations/v40-v40.ts b/src/main/database/migrations/v40-v40.ts new file mode 100644 index 00000000..8fabe5cf --- /dev/null +++ b/src/main/database/migrations/v40-v40.ts @@ -0,0 +1,89 @@ +import log from "electron-log/main.js"; +import type { Migration, MigrationDeps } from "./types"; +import { tableExists } from "./types"; + +export function createMigrationsV40V40(deps: MigrationDeps): Migration[] { + const { db } = deps; + return [ + { + // Relax work_products.task_id from NOT NULL to NULL-allowed. + // + // Background: every file that an agent writes via pi's write/edit tool + // should be registered as a work product so the desktop UI surfaces + // it. Heartbeat (taskless) runs — CEO team-building scratch memos, + // research exploration — legitimately produce files with no owning + // task. Forcing task_id NOT NULL made the artifact projector skip + // these runs, which in turn made the UI look empty while the + // workspace filesystem filled up with unregistered output. + // + // SQLite has no ALTER COLUMN, so we rebuild the table. The operation + // is done inside a transaction; PRAGMA foreign_keys is disabled for + // the rebuild window and restored afterward so child rows do not + // cascade-delete when the old table is dropped mid-swap. + version: 40, + name: "relax_work_products_task_id", + fn: () => { + if (!tableExists(db, "work_products")) { + log.info("[migration v40] work_products table missing; skipping"); + return; + } + const info = db.prepare("PRAGMA table_info(work_products)").all() as Array<{ + name: string; + notnull: number; + }>; + const taskIdRow = info.find((row) => row.name === "task_id"); + if (!taskIdRow) { + throw new Error("work_products.task_id column missing; refusing to migrate v40"); + } + if (taskIdRow.notnull === 0) { + log.info("[migration v40] work_products.task_id already nullable; skipping"); + return; + } + + db.exec("PRAGMA foreign_keys = OFF"); + try { + db.exec(` + CREATE TABLE work_products_new ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + project_id TEXT, + task_id TEXT, + type TEXT NOT NULL, + provider TEXT NOT NULL, + external_id TEXT, + title TEXT NOT NULL, + url TEXT, + status TEXT NOT NULL DEFAULT 'active', + review_state TEXT NOT NULL DEFAULT 'none', + is_primary INTEGER NOT NULL DEFAULT 0, + summary TEXT, + metadata_json TEXT, + created_by_run_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + INSERT INTO work_products_new ( + id, company_id, project_id, task_id, type, provider, external_id, + title, url, status, review_state, is_primary, summary, metadata_json, + created_by_run_id, created_at, updated_at + ) + SELECT id, company_id, project_id, task_id, type, provider, external_id, + title, url, status, review_state, is_primary, summary, metadata_json, + created_by_run_id, created_at, updated_at + FROM work_products; + DROP TABLE work_products; + ALTER TABLE work_products_new RENAME TO work_products; + CREATE INDEX IF NOT EXISTS idx_work_products_task + ON work_products(company_id, task_id, type); + CREATE INDEX IF NOT EXISTS idx_work_products_updated + ON work_products(company_id, updated_at); + `); + } finally { + db.exec("PRAGMA foreign_keys = ON"); + } + log.info("[migration v40] relaxed work_products.task_id to nullable"); + }, + }, + ]; +} diff --git a/src/main/database/model-capabilities-crud.ts b/src/main/database/model-capabilities-crud.ts new file mode 100644 index 00000000..c6382a93 --- /dev/null +++ b/src/main/database/model-capabilities-crud.ts @@ -0,0 +1,111 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ModelCapability } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { nowIso } from "./helpers"; + +/** + * CRUD for `model_capabilities`. The table is (provider, model_id) keyed; the + * dispatcher reads it on every run, so every row must have numeric scores — + * we validate inputs at the application layer before touching the DB. + */ +export function buildModelCapabilityMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + type Row = { + provider: string; + model_id: string; + reasoning: number; + coding: number; + writing: number; + tier: "trivial" | "standard" | "hard"; + cost_per_mtok_out: number; + updated_at: string; + }; + + function rowToRecord(row: Row): ModelCapability { + return { + provider: row.provider, + modelId: row.model_id, + reasoning: row.reasoning, + coding: row.coding, + writing: row.writing, + tier: row.tier, + costPerMtokOut: row.cost_per_mtok_out, + updatedAt: row.updated_at, + }; + } + + function listModelCapabilities(): ModelCapability[] { + return readAll( + "SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities ORDER BY provider, model_id", + ).map(rowToRecord); + } + + function getModelCapability(provider: string, modelId: string): ModelCapability | null { + const row = getDb() + .prepare( + "SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities WHERE provider = ? AND model_id = ?", + ) + .get(provider, modelId) as Row | undefined; + return row ? rowToRecord(row) : null; + } + + function upsertModelCapability(input: Omit): void { + const now = nowIso(); + getDb() + .prepare( + `INSERT INTO model_capabilities (provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, model_id) DO UPDATE SET + reasoning = excluded.reasoning, + coding = excluded.coding, + writing = excluded.writing, + tier = excluded.tier, + cost_per_mtok_out = excluded.cost_per_mtok_out, + updated_at = excluded.updated_at`, + ) + .run( + input.provider, + input.modelId, + input.reasoning, + input.coding, + input.writing, + input.tier, + input.costPerMtokOut, + now, + ); + } + + function deleteModelCapability(provider: string, modelId: string): void { + getDb() + .prepare("DELETE FROM model_capabilities WHERE provider = ? AND model_id = ?") + .run(provider, modelId); + } + + /** + * Seed the table from a list of rows. Used at boot when the table is empty + * so a fresh install has something to dispatch against. Runs inside a + * single transaction so either every row is written or none — we don't + * want partial seeding if something goes wrong mid-loop. + */ + function seedModelCapabilities(rows: Array>): number { + return transaction(() => { + const existing = readAll<{ cnt: number }>( + "SELECT COUNT(*) AS cnt FROM model_capabilities", + )[0]?.cnt ?? 0; + if (existing > 0) return 0; + for (const row of rows) upsertModelCapability(row); + return rows.length; + }); + } + + return { + listModelCapabilities, + getModelCapability, + upsertModelCapability, + deleteModelCapability, + seedModelCapabilities, + }; +} diff --git a/src/main/database/runs.ts b/src/main/database/runs.ts index 9ecb8a7a..717653a0 100644 --- a/src/main/database/runs.ts +++ b/src/main/database/runs.ts @@ -96,10 +96,19 @@ export function buildRunMethods( sessionDisplayId: string | null; unattributedCost: boolean; }) { - const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as - | { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId } + const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId, started_at as startedAt from runs where id = ?").get(input.runId) as + | { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId; startedAt: string | null } | undefined; if (!run) return { budgetHardStopped: false }; + // A run that never transitioned to 'running' (startedAt === null) and + // is now terminal with status 'failed' / 'cancelled' must roll the + // task back to 'todo'. Otherwise the task is stuck in 'in_progress' + // with no owner — checkoutTask refuses (WHERE status IN todo/backlog), + // and the scheduler loops forever logging "lost checkout race". The + // common trigger is workspace-lock contention: `dispatchRunToWorker` + // bails before work starts and calls finishRun('failed'). + const runNeverStarted = + run.startedAt === null && (input.status === "failed" || input.status === "cancelled"); const ts = nowIso(); // Cost is owned by CostWriter — per-message rows in `cost_entries`. @@ -137,6 +146,33 @@ export function buildRunMethods( getDb() .prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?") .run(ts, run.taskId, input.runId); + if (runNeverStarted) { + // Roll the task back to `todo` when no *other* live run claims + // it. Invariant: `task.status = 'in_progress'` ⇒ a queued or + // running run owns it. We drop the previous + // `checkout_run_id IS NULL AND active_run_id IS NULL` guard + // because the conditional UPDATE two lines up can miss (race: + // checkout_run_id already swapped to a newer run id), which + // would leave the task silently stuck at in_progress. The + // NOT EXISTS clause is the canonical "no live owner" check. + getDb() + .prepare( + `UPDATE tasks + SET status = 'todo', + checkout_run_id = NULL, + execution_locked_at = NULL, + updated_at = ? + WHERE id = ? + AND status = 'in_progress' + AND NOT EXISTS ( + SELECT 1 FROM runs r + WHERE r.task_id = tasks.id + AND r.id != ? + AND r.status IN ('queued', 'running') + )`, + ) + .run(ts, run.taskId, input.runId); + } } if (runCostUsd > 0) { if (run.taskId !== null) { diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 3a4b1c2a..93e1afe2 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -3,6 +3,8 @@ import { createMigrationsV1V15 } from "./migrations/v1-v15"; import { createMigrationsV16V25 } from "./migrations/v16-v25"; import { createMigrationsV26V34 } from "./migrations/v26-v34"; import { createMigrationsV35V37 } from "./migrations/v35-v37"; +import { createMigrationsV38V39 } from "./migrations/v38-v39"; +import { createMigrationsV40V40 } from "./migrations/v40-v40"; import { columnExists, nowIso, @@ -172,6 +174,8 @@ export function runMigrations( ...createMigrationsV16V25(deps), ...createMigrationsV26V34(deps), ...createMigrationsV35V37(deps), + ...createMigrationsV38V39(deps), + ...createMigrationsV40V40(deps), ]; for (const migration of migrations) { diff --git a/src/main/database/snapshot.ts b/src/main/database/snapshot.ts index 075f2ce8..71cb8c57 100644 --- a/src/main/database/snapshot.ts +++ b/src/main/database/snapshot.ts @@ -63,7 +63,7 @@ export function buildSnapshotMethods( ...companyArgs, ).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[], agents: readAll( - `select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`, + `select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`, ...companyArgs, ).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[], goals: readAll( diff --git a/src/main/database/task-sessions-crud.ts b/src/main/database/task-sessions-crud.ts index c963a1b7..b744c883 100644 --- a/src/main/database/task-sessions-crud.ts +++ b/src/main/database/task-sessions-crud.ts @@ -122,6 +122,29 @@ export function buildTaskSessionMethods( ); }, + /** + * Fetch a session by its primary key. Sessions are agent-scoped + * (post-v34 migration): a heartbeat run has no task to dereference, + * so the session lookup must take the session id directly. Use with + * `agents.session_id` as the entry point. + */ + getSessionById(sessionId: string): TaskSessionRecord | null { + return ( + (getDb() + .prepare( + `SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId, + adapter_type as adapterType, session_params_json as sessionParamsJson, + session_display_id as sessionDisplayId, last_run_id as lastRunId, + last_error as lastError, run_count as runCount, + total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens, + rotated_at as rotatedAt, rotation_reason as rotationReason, + handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt + FROM task_sessions WHERE id = ?`, + ) + .get(sessionId) as TaskSessionRecord | undefined) ?? null + ); + }, + incrementSessionUsage( sessionId: string, inputTokens: number, diff --git a/src/main/ipc-core-agent-graph-handlers.ts b/src/main/ipc-core-agent-graph-handlers.ts index 0eaae98f..7f6dbf4f 100644 --- a/src/main/ipc-core-agent-graph-handlers.ts +++ b/src/main/ipc-core-agent-graph-handlers.ts @@ -115,6 +115,10 @@ export function registerCoreAgentGraphHandlers({ metadataJson: parsed.metadataJson, capabilities: parsed.capabilities, budgetMonthlyUsd: parsed.budgetMonthlyUsd, + // User edited this agent through the form and picked a specific + // (connector, model). Respect their intent: pin the strategy to + // `explicit` so the dispatcher never overrides their choice. + modelStrategy: "explicit", }); } catch (error) { return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved."); diff --git a/src/main/ipc-support.ts b/src/main/ipc-support.ts index 0de42a7c..c9e496df 100644 --- a/src/main/ipc-support.ts +++ b/src/main/ipc-support.ts @@ -81,10 +81,34 @@ export function createIpcSupport({ }); } + // Transport-layer failures when the renderer is mid-reload or already + // disposed throw "Render frame was disposed before WebFrameMain could + // be accessed" out of Electron's IPC core. Those exceptions are a + // lifecycle signal, never a logic bug — but if they bubble, they + // abort every downstream event in the same tick (RuntimeBridge fans + // two emits per agent-event, the second one never runs when the first + // throws). Treat emitEvent as a fire-and-forget sink: renderer + // disappearances are silent, real serialisation errors are warned + // once per event type. + const renderFrameDisposedMessage = "Render frame was disposed"; + const emitWarnedChannels = new Set(); + function emitEvent(event: DesktopEvent) { const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow || mainWindow.isDestroyed()) return; + try { mainWindow.webContents.send("agent-company:event", event); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(renderFrameDisposedMessage)) { + // Renderer is reloading or gone — expected; skip. + return; + } + const key = event.type ?? ""; + if (!emitWarnedChannels.has(key)) { + emitWarnedChannels.add(key); + logger.error(`[ipc] emitEvent(${key}) failed: ${message}`); + } } } diff --git a/src/main/model-capability-seed.ts b/src/main/model-capability-seed.ts new file mode 100644 index 00000000..beb2b11f --- /dev/null +++ b/src/main/model-capability-seed.ts @@ -0,0 +1,156 @@ +import type { ModelCapability } from "@shared/types"; + +/** + * Initial capability matrix written into the DB on first boot when the + * table is empty. The user's five-axis ranking from the dispatch design + * discussion is compressed to three axes: + * + * reasoning = architecture/review ∧ math ∧ research + * coding = coding (unchanged) + * writing = language/copy (unchanged — the other two collapsed upward) + * + * Scores are 1..5 on each axis. Tier is the hard lower bound the dispatcher + * clears before considering `costPerMtokOut` — a tier-3 (`hard`) task never + * runs on a tier-1 (`trivial`) model even if the trivial model's domain + * score happens to clear the numeric threshold. + * + * Cost-per-Mtok-output figures are the public list price at the provider's + * own API as of 2026-04 (approximate; the table is user-editable after + * seeding so prices can be corrected in-app without a code change). + * + * Adding a new model: append a row here AND publish a migration or an IPC + * call that writes it through `upsertModelCapability` — the seed only runs + * when the table is empty, so it does not back-fill on upgrade. + */ + +export const MODEL_CAPABILITY_SEED: Array> = [ + // ── Anthropic (Claude) ──────────────────────────────────────────────────── + // Claude Opus 4.7: strong across the board, top coding, top writing. + { + provider: "anthropic", + modelId: "claude-opus-4-7", + reasoning: 5, + coding: 5, + writing: 5, + tier: "hard", + costPerMtokOut: 75, + }, + { + provider: "anthropic", + modelId: "claude-sonnet-4-6", + reasoning: 4, + coding: 5, + writing: 5, + tier: "standard", + costPerMtokOut: 15, + }, + { + provider: "anthropic", + modelId: "claude-haiku-4-5-20251001", + reasoning: 3, + coding: 4, + writing: 4, + // Haiku sits at the standard tier. Its scores already clear the + // standard domain floor (>=3), so marking the tier any lower would + // exclude it from standard tasks in the dispatcher's tier-rank guard. + // The cost advantage (~5x cheaper than Sonnet) is the whole point. + tier: "standard", + costPerMtokOut: 5, + }, + + // ── OpenAI (GPT) ────────────────────────────────────────────────────────── + // GPT family leads reasoning/architecture/math/research; coding trails + // Claude; writing trails Claude and Kimi. + { + provider: "openai", + modelId: "gpt-5", + reasoning: 5, + coding: 4, + writing: 3, + tier: "hard", + costPerMtokOut: 60, + }, + { + provider: "openai", + modelId: "gpt-5-mini", + reasoning: 4, + coding: 3, + writing: 3, + tier: "standard", + costPerMtokOut: 10, + }, + { + provider: "openai", + modelId: "gpt-4o-mini", + reasoning: 3, + coding: 3, + writing: 3, + // Same reasoning as Haiku: the model clears the standard domain + // floor, so it should live at the standard tier. Llama-70B below is + // the only genuinely trivial-tier seed row. + tier: "standard", + costPerMtokOut: 2, + }, + + // ── Fireworks curated (open-weight) ─────────────────────────────────────── + // Kimi K2: strong writing and coding, near-Claude. Reasoning solid. + { + provider: "fireworks", + modelId: "accounts/fireworks/models/kimi-k2-instruct", + reasoning: 4, + coding: 4, + writing: 5, + tier: "standard", + costPerMtokOut: 3, + }, + // Kimi K2.5 Turbo via Fire Pass router — newer, faster, similar quality. + { + provider: "fireworks-firepass", + modelId: "accounts/fireworks/routers/kimi-k2p5-turbo", + reasoning: 4, + coding: 4, + writing: 5, + tier: "standard", + costPerMtokOut: 3, + }, + // GLM 4.5: mid-tier across reasoning and coding, decent writing. + { + provider: "fireworks", + modelId: "accounts/fireworks/models/glm-4p5", + reasoning: 3, + coding: 3, + writing: 4, + tier: "standard", + costPerMtokOut: 2, + }, + // Qwen3 Coder 480B: coding specialist. + { + provider: "fireworks", + modelId: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", + reasoning: 3, + coding: 5, + writing: 3, + tier: "standard", + costPerMtokOut: 3, + }, + // DeepSeek V3.1: strong reasoning, capable coder, writing ok. + { + provider: "fireworks", + modelId: "accounts/fireworks/models/deepseek-v3p1", + reasoning: 4, + coding: 4, + writing: 3, + tier: "standard", + costPerMtokOut: 2, + }, + // Llama 3.3 70B: generalist trivial-tier fallback. + { + provider: "fireworks", + modelId: "accounts/fireworks/models/llama-v3p3-70b-instruct", + reasoning: 2, + coding: 2, + writing: 2, + tier: "trivial", + costPerMtokOut: 1, + }, +]; diff --git a/src/main/model-dispatch.ts b/src/main/model-dispatch.ts new file mode 100644 index 00000000..23c8d023 --- /dev/null +++ b/src/main/model-dispatch.ts @@ -0,0 +1,209 @@ +import log from "electron-log/main.js"; +import type { + AgentRecord, + ConnectorId, + ConnectorRecord, + ModelCapability, + TaskTier, +} from "@shared/types"; +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; +import { getConnectorDefinition } from "./connectors"; +import { getConnectorModelPolicy } from "./connector-model-policy"; +import { inferTaskProfile } from "./agent-role-helpers"; + +/** + * Resolves which (provider, modelId) to use for an agent at run dispatch. + * + * The rule is spelled out in the design discussion: + * + * 1. `explicit` strategy (or a CEO role): + * use agent.connectorId + agent.model verbatim. CEO is always pinned + * to whatever the user chose on the Agent form. + * + * 2. Only one enabled (provider, modelId) pair exists anywhere: + * use it. Whether it's the CEO's pick or a lone API-key connector, + * single-model installs should not exercise the dispatcher at all. + * + * 3. `auto` strategy with multiple candidates: + * infer the agent's task profile (domain ∈ reasoning|coding|writing, + * required tier ∈ trivial|standard|hard), filter enabled candidates + * to those whose capability row clears both the domain score and the + * tier floor, and pick the cheapest among them. + * + * 4. No candidate clears the bar in `auto`: + * fall back to the CEO's (connectorId, model). Runs must not fail + * because the capability matrix is under-seeded — No regressions. + */ + +export interface EnabledModel { + provider: string; + connectorId: ConnectorId; + modelId: string; +} + +export interface AgentAssignment { + connectorId: ConnectorId; + modelId: string | null; +} + +export interface PickModelDeps { + getConnector: (id: ConnectorId) => ConnectorRecord; + getDisabledModels: (connectorId: string) => Array<{ id: string; label: string }>; + /** + * Thin projection of the CEO for fallback. Returns `null` when the + * company has no CEO yet (pre-bootstrap). The picker only needs enough + * to route a run, so we don't demand the full AgentRecord — keeping the + * surface narrow lets callers use the existing `db.getCeoForCompany` + * together with a single `getAgent` lookup for the fallback pair. + */ + getCeoAssignment: (companyId: string) => { connectorId: ConnectorId; modelId: string | null } | null; + listModelCapabilities: () => ModelCapability[]; + readConnectors: () => ConnectorRecord[]; +} + +/** + * Minimum numeric score per tier — the model must score *at least* this on + * the task's domain axis. Higher tiers demand strictly higher scores; a + * tier-3 (hard) task refuses any model that scores below 4 on its domain + * regardless of the model's own tier flag. Tuned to the 5-grade rubric. + */ +const DOMAIN_SCORE_FLOOR: Record = { + trivial: 2, + standard: 3, + hard: 4, +}; + +const TIER_RANK: Record = { + trivial: 1, + standard: 2, + hard: 3, +}; + +/** + * Walk every connector the user has configured, keep the ones that are + * execution-ready (authenticated, healthy), and expand each to its enabled + * model list. Returns a flat list of (provider, connectorId, modelId) triples + * — the concrete universe the picker chooses from. + */ +export function listEnabledModels(deps: PickModelDeps): EnabledModel[] { + const result: EnabledModel[] = []; + for (const connector of deps.readConnectors()) { + if (getConnectorExecutionReadinessIssue(connector)) continue; + let definition; + try { + definition = getConnectorDefinition(connector.id); + } catch (err) { + log.warn(`[model-dispatch] Skipping unknown connector ${connector.id}:`, err instanceof Error ? err.message : String(err)); + continue; + } + const policy = getConnectorModelPolicy(deps, connector.id as ConnectorId); + for (const model of policy.enabledModels) { + result.push({ + provider: definition.provider, + connectorId: connector.id as ConnectorId, + modelId: model.id, + }); + } + } + return result; +} + +/** + * Pure scoring step. Given the agent's inferred profile, the capability + * matrix, and the concrete list of enabled (provider, modelId) pairs, + * pick the cheapest qualifying model — or return `null` when nothing + * clears the bar so the caller can apply its fallback. + * + * Split out from `pickModelForAgent` so unit tests can exercise the + * rubric without having to stand up the real connector catalog or + * policy lookups. + */ +export function pickFromCandidates( + profile: { domain: "reasoning" | "coding" | "writing"; requiredTier: TaskTier }, + enabled: EnabledModel[], + capabilities: ModelCapability[], +): EnabledModel | null { + if (enabled.length === 0) return null; + const domainFloor = DOMAIN_SCORE_FLOOR[profile.requiredTier]; + const tierFloor = TIER_RANK[profile.requiredTier]; + + const capabilityByKey = new Map(); + for (const cap of capabilities) { + capabilityByKey.set(`${cap.provider}::${cap.modelId}`, cap); + } + + const qualifying: Array<{ model: EnabledModel; cap: ModelCapability }> = []; + for (const model of enabled) { + const cap = capabilityByKey.get(`${model.provider}::${model.modelId}`); + if (!cap) continue; + if (cap[profile.domain] < domainFloor) continue; + if (TIER_RANK[cap.tier] < tierFloor) continue; + qualifying.push({ model, cap }); + } + if (qualifying.length === 0) return null; + + qualifying.sort((a, b) => a.cap.costPerMtokOut - b.cap.costPerMtokOut); + return qualifying[0]!.model; +} + +/** + * The one-call entry point that run dispatch uses. Returns the pair to + * burn into the run record; callers pass it straight into `createRun` and + * the worker payload. + */ +export function pickModelForAgent( + agent: AgentRecord, + deps: PickModelDeps, +): AgentAssignment { + const strategy = agent.modelStrategy ?? "explicit"; + + // CEO is pinned to the user's choice. The canonical CEO marker is + // `reportsTo === null` — the DB invariant (`getCeoForCompany` SELECTs on + // the same predicate), so this holds even when `roleLevel` is still the + // schema default 'ic' on an agent that was never tagged with the full + // seniority enum. We also accept the enum for completeness. + const isCeo = agent.reportsTo === null || agent.roleLevel === "c_suite"; + if (strategy === "explicit" || isCeo) { + return { connectorId: agent.connectorId, modelId: agent.model ?? null }; + } + + const enabled = listEnabledModels(deps); + + // Single-model install: there's nothing to pick from. Use it. + if (enabled.length === 1) { + const only = enabled[0]!; + return { connectorId: only.connectorId, modelId: only.modelId }; + } + + // Empty universe → fall back to whatever is on the agent row. This can + // only happen when every connector is unhealthy; the eligibility checks + // higher up in run dispatch will still block the run, but we return + // something sensible so the caller's contract stays total. + if (enabled.length === 0) { + return { connectorId: agent.connectorId, modelId: agent.model ?? null }; + } + + const profile = inferTaskProfile(agent.role, agent.title, agent.roleLevel); + const winner = pickFromCandidates(profile, enabled, deps.listModelCapabilities()); + + if (!winner) { + // Fall back to the CEO's explicit pair. If there is no CEO (fresh + // company, board-only), fall back to agent's own row so we never + // throw. The upstream readiness check owns whether to let the run + // actually start. + const ceo = deps.getCeoAssignment(agent.companyId); + if (ceo) return ceo; + return { connectorId: agent.connectorId, modelId: agent.model ?? null }; + } + + return { connectorId: winner.connectorId, modelId: winner.modelId }; +} + +/** + * Re-export for test setups that want to exercise the rubric without + * standing up a full AgentRecord. + */ +export const __INTERNAL__ = { + DOMAIN_SCORE_FLOOR, + TIER_RANK, +}; diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index fab944eb..f06db3f8 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -257,6 +257,10 @@ export function createOnboardingService({ : "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.", budgetMonthlyUsd: 250, status: "idle", + // CEO is pinned to the user's pick at onboarding. Never let the + // dispatcher re-route it. + modelStrategy: "explicit", + roleLevel: "c_suite", }); db.setHeartbeat(ceoId, true, 120); db.setCurrentCompany(companyId); diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts index 817af03a..c5f421e0 100644 --- a/src/main/orchestration/heartbeat.ts +++ b/src/main/orchestration/heartbeat.ts @@ -3,6 +3,7 @@ import type { AppDatabase } from "../database"; import type { AgentRecord, ConnectorRecord } from "@shared/types"; import type { OrchestrationDependencies } from "./types"; import type { RunDispatchParams } from "./turn-context"; +import { pickModelForAgent } from "../model-dispatch"; export function createHeartbeat( deps: OrchestrationDependencies, @@ -41,8 +42,21 @@ export function createHeartbeat( logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`); return null; } - const connector = db.getConnector(agent.connectorId); - if (getRunEligibilityFailure(agent, connector, null)) { + const assignment = pickModelForAgent(agent, { + getConnector: (id) => db.getConnector(id), + getDisabledModels: (id) => db.getDisabledModels(id), + getCeoAssignment: (cid) => { + const ceo = db.getCeoForCompany(cid); + if (!ceo) return null; + const full = db.getAgent(ceo.id); + return { connectorId: full.connectorId, modelId: full.model ?? null }; + }, + listModelCapabilities: () => db.modelCapabilities.listModelCapabilities(), + readConnectors: () => db.connectorsCrud.readConnectors(), + }); + const connector = db.getConnector(assignment.connectorId); + const effectiveAgentForEligibility: AgentRecord = { ...agent, model: assignment.modelId }; + if (getRunEligibilityFailure(effectiveAgentForEligibility, connector, null)) { return null; } // Extended heartbeat policy checks (Paperclip parity). @@ -182,6 +196,7 @@ export function createHeartbeat( companyDescription: companyBasic?.description ?? "", snapshot: db.listSnapshot(), trigger, + resolvedModel: assignment.modelId, }); db.markHeartbeatAt(agentId); diff --git a/src/main/orchestration/resolve-workspace.ts b/src/main/orchestration/resolve-workspace.ts index a5a5f536..87d61b98 100644 --- a/src/main/orchestration/resolve-workspace.ts +++ b/src/main/orchestration/resolve-workspace.ts @@ -4,10 +4,32 @@ import type { WorkspaceLockMode } from "./types"; /** * Resolve the workspace and lock semantics for a given run context. * - * Previously this logic lived both in run-dispatch.ts (normal dispatch path) - * and worker-crash-recovery.ts (re-enqueue after crash); the recovery - * copy silently dropped the exclusive/shared distinction. Sharing the - * helper guarantees both paths agree on which runs hold exclusive locks. + * Lock policy (v0.4.168+): + * + * task.workspaceId set → exclusive + * The user (or an upstream caller) explicitly bound a task to a + * workspace, which is the opt-in signal for "this run needs the + * workspace to itself". Rare, and reserved for workflows like a + * sprint merge or release build where concurrent writes would + * corrupt state. + * + * agent.workspaceId set → shared + * project.workspaceId set → shared + * company fallback → shared + * Multiple agents sharing the *same* workspace is the normal case + * — it represents "this team works in this repo", not "this agent + * owns this repo". The previous exclusive default forced four + * co-located teammates to run one at a time, which deadlocked + * heartbeats (dispatch bailed → task stuck in_progress → retry + * lost the checkout race forever). Git itself serializes the + * dangerous operations (`git index.lock`); anything coarser here + * just blocks readers. + * + * Previously this logic lived both in run-dispatch.ts (normal dispatch + * path) and worker-crash-recovery.ts (re-enqueue after crash); the + * recovery copy silently dropped the exclusive/shared distinction. + * Sharing the helper guarantees both paths agree on which runs hold + * exclusive locks. */ export function resolveWorkspaceWithLockMode( db: AppDatabase, @@ -28,12 +50,12 @@ export function resolveWorkspaceWithLockMode( const agentWorkspace = db.getWorkspace(agent.workspaceId); if (agentWorkspace) { - return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" }; + return { workspace: agentWorkspace, lockMode: "shared", source: "agent" }; } const projectWorkspace = db.getProjectPrimaryWorkspace(task?.projectId ?? null, companyId); if (projectWorkspace) { - return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" }; + return { workspace: projectWorkspace, lockMode: "shared", source: "project" }; } const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId); diff --git a/src/main/orchestration/run-dispatch.ts b/src/main/orchestration/run-dispatch.ts index b32d245f..c82222af 100644 --- a/src/main/orchestration/run-dispatch.ts +++ b/src/main/orchestration/run-dispatch.ts @@ -71,6 +71,13 @@ export function createRunDispatch(deps: OrchestrationDependencies) { // Previously the worker claimed the lock only after it emitted `running`, // so two runs could race between enqueue and startup and both read the // same workspace mid-write. Main is the authoritative coordinator. + // + // On contention we mark the run `cancelled`, not `failed`. The Dashboard + // "Needs attention" card only surfaces failed/timed_out/interrupted runs + // (see src/renderer/components/dashboard/Dashboard.tsx), so the user is + // not alarmed by a transient backoff. The task row is rolled back to + // `todo` via the `finishRun` never-started branch, so heartbeat picks + // it up on the next cycle with no manual intervention. if ( params.workspaceMode === "exclusive" && params.workspace && @@ -78,9 +85,9 @@ export function createRunDispatch(deps: OrchestrationDependencies) { ) { db.finishRun({ runId: params.runId, - status: "failed", + status: "cancelled", summary: null, - errorMessage: `Workspace ${params.workspace.id} is locked by another run.`, + errorMessage: `Workspace ${params.workspace.id} is in use by another run; will retry on the next heartbeat.`, exitCode: null, signal: null, model: null, diff --git a/src/main/orchestration/task-runner.ts b/src/main/orchestration/task-runner.ts index 44219433..7f700237 100644 --- a/src/main/orchestration/task-runner.ts +++ b/src/main/orchestration/task-runner.ts @@ -4,6 +4,7 @@ import type { AgentRecord, ConnectorRecord } from "@shared/types"; import type { DesktopResult } from "@shared/contracts"; import type { OrchestrationDependencies } from "./types"; import type { RunDispatchParams } from "./turn-context"; +import { pickModelForAgent } from "../model-dispatch"; export function createTaskRunner( deps: OrchestrationDependencies, @@ -46,8 +47,21 @@ export function createTaskRunner( if (agent.companyId !== task.companyId) { return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company."); } - const connector = db.getConnector(agent.connectorId); - const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null); + const assignment = pickModelForAgent(agent, { + getConnector: (id) => db.getConnector(id), + getDisabledModels: (id) => db.getDisabledModels(id), + getCeoAssignment: (cid) => { + const ceo = db.getCeoForCompany(cid); + if (!ceo) return null; + const full = db.getAgent(ceo.id); + return { connectorId: full.connectorId, modelId: full.model ?? null }; + }, + listModelCapabilities: () => db.modelCapabilities.listModelCapabilities(), + readConnectors: () => db.connectorsCrud.readConnectors(), + }); + const connector = db.getConnector(assignment.connectorId); + const effectiveAgentForEligibility: AgentRecord = { ...agent, model: assignment.modelId }; + const eligibilityFailure = getRunEligibilityFailure(effectiveAgentForEligibility, connector, task.projectId ?? null); if (eligibilityFailure === "AGENT_NOT_RUNNABLE") { return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated."); } @@ -109,6 +123,7 @@ export function createTaskRunner( companyDescription: companyBasic?.description ?? "", snapshot: db.listSnapshot(), trigger, + resolvedModel: assignment.modelId, }); db.addActivity({ companyId: task.companyId, diff --git a/src/main/orchestration/turn-context.ts b/src/main/orchestration/turn-context.ts index 0070402a..098291ff 100644 --- a/src/main/orchestration/turn-context.ts +++ b/src/main/orchestration/turn-context.ts @@ -29,6 +29,14 @@ export interface RunDispatchParams { companyDescription: string; snapshot: ReturnType; trigger: string; + /** + * Model id resolved by `pickModelForAgent` at dispatch time. When the + * agent runs under `modelStrategy = "auto"` this differs from + * `agent.model` (the column now becomes a legacy fallback). Optional so + * explicit-strategy callers and legacy tests keep working; when absent + * we read `agent.model` verbatim. + */ + resolvedModel?: string | null; } export interface TurnContextDeps { @@ -37,10 +45,11 @@ export interface TurnContextDeps { getApiPort: () => number; createRunToken: (agentId: string, companyId: string, runId: string) => string; listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[]; - getSessionHandoff?: (taskId: string) => string | null; - evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => { + getSessionHandoff?: (agentId: string) => string | null; + evaluatePreRunSessionRotation?: (agentId: string, connectorId: string) => { rotated: boolean; handoff: string | null; + sessionId: string | null; }; } @@ -94,14 +103,14 @@ function assembleRecentComments( } function resolveSessionHandoff( - taskId: string, + agentId: string, deps: Pick, ): string | null { if (!deps.getSessionHandoff) return null; try { - return deps.getSessionHandoff(taskId); + return deps.getSessionHandoff(agentId); } catch (err) { - deps.logger.warn(`[run] Failed to load session handoff for task ${taskId}: ${err instanceof Error ? err.message : String(err)}`); + deps.logger.warn(`[run] Failed to load session handoff for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`); return null; } } @@ -138,7 +147,8 @@ function resolveSession( deps: Pick, ) { // Per-agent session: one ongoing transcript per agent; switching tasks - // becomes a "now work on X" user turn rather than a fresh session. + // (or running taskless heartbeats) becomes a "now work on X" user turn + // on the ongoing transcript rather than starting a fresh session. const agentSessionId = db.ensureAgentSession({ agentId: agent.id, companyId, @@ -148,35 +158,15 @@ function resolveSession( // that read per-task active_session_id keep working. db.updateTasksActiveSessionForAgent(agent.id, agentSessionId); - // Heartbeat runs have no task; reuse the agent-scoped session with no - // task-specific active session lookup. - if (!task) { - return { - taskSession: { id: agentSessionId, sessionParams: null, sessionDisplayId: null }, - sessionHandoff, - }; - } - - const activeSession = db.getActiveTaskSession(task.id); - let taskSession: { - id: string | null; - sessionParams: Record | null; - sessionDisplayId: string | null; - } = activeSession - ? { - id: activeSession.id, - sessionParams: activeSession.sessionParamsJson - ? (JSON.parse(activeSession.sessionParamsJson) as Record) - : null, - sessionDisplayId: activeSession.sessionDisplayId, - } - : { id: agentSessionId, sessionParams: null, sessionDisplayId: null }; - + // Evaluate rotation against the agent-scoped session unconditionally. + // Heartbeat runs (no task) must rotate too, or their transcript grows + // forever — the exact failure mode that burned through 7M token windows. + let currentSessionId = agentSessionId; let finalHandoff = sessionHandoff; - if (deps.evaluatePreRunSessionRotation && activeSession) { - const rotationResult = deps.evaluatePreRunSessionRotation(task.id, connectorId); + if (deps.evaluatePreRunSessionRotation) { + const rotationResult = deps.evaluatePreRunSessionRotation(agent.id, connectorId); if (rotationResult.rotated) { - taskSession = { id: null, sessionParams: null, sessionDisplayId: null }; + currentSessionId = rotationResult.sessionId ?? agentSessionId; if (!finalHandoff && rotationResult.handoff) { finalHandoff = rotationResult.handoff; } @@ -184,7 +174,24 @@ function resolveSession( } } - return { taskSession, sessionHandoff: finalHandoff }; + // `sessionParams` / `sessionDisplayId` are transient per-run state pi-ai + // stamps onto the task row after a turn (e.g., Gemini's resume id). They + // are only meaningful when a task's cached pointer still matches the + // current (unrotated) session; after rotation the new session has none. + let sessionParams: Record | null = null; + let sessionDisplayId: string | null = null; + if (task) { + const active = db.getActiveTaskSession(task.id); + if (active && active.id === currentSessionId && active.sessionParamsJson) { + sessionParams = JSON.parse(active.sessionParamsJson) as Record; + sessionDisplayId = active.sessionDisplayId; + } + } + + return { + taskSession: { id: currentSessionId, sessionParams, sessionDisplayId }, + sessionHandoff: finalHandoff, + }; } function extractThinkingEffort( @@ -284,7 +291,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]); const reviewTasks = db.listReviewTasksForManager(agent.id, companyId); const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot); - const initialHandoff = task ? resolveSessionHandoff(task.id, deps) : null; + const initialHandoff = resolveSessionHandoff(agent.id, deps); const { taskSession, sessionHandoff } = resolveSession(db, agent, task, companyId, connector.id, initialHandoff, deps); const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps); @@ -335,7 +342,8 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps }) : null; const thinkingEffort = extractThinkingEffort(agent.metadataJson, deps.logger, agent.id); - const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model); + const modelForRun = params.resolvedModel !== undefined ? params.resolvedModel : agent.model; + const modelSelection = validateConnectorModelSelection(db, connector.id, modelForRun); if (!modelSelection.ok) { throw new Error(modelSelection.error); } diff --git a/src/main/orchestration/types.ts b/src/main/orchestration/types.ts index 62431da0..dfa953c5 100644 --- a/src/main/orchestration/types.ts +++ b/src/main/orchestration/types.ts @@ -71,9 +71,15 @@ export interface OrchestrationDependencies { budgetService: ReturnType; /** Checkpoint engine for unified co-founder mode pause decisions. */ checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean }; - getSessionHandoff?: (taskId: string) => string | null; - evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => { + getSessionHandoff?: (agentId: string) => string | null; + evaluatePreRunSessionRotation?: (agentId: string, connectorId: string) => { rotated: boolean; handoff: string | null; + /** + * The session id the next run should use. Equals the freshly-minted + * session id after a rotation, or the unchanged agent session id when + * no rotation fired. Null if the agent has no session yet (first run). + */ + sessionId: string | null; }; } diff --git a/src/main/prompt-builder/ceo-modes/team-building.en.md b/src/main/prompt-builder/ceo-modes/team-building.en.md index 790381d3..6c37532f 100644 --- a/src/main/prompt-builder/ceo-modes/team-building.en.md +++ b/src/main/prompt-builder/ceo-modes/team-building.en.md @@ -1,48 +1,73 @@ # Operating Instructions (CEO — Team Building Phase) -You are the new CEO. Your company has no active team members yet. Your job is to BUILD this company from scratch. +You are the new CEO. Your company has no active team members yet. Your single job in this wake is to **staff the company**. Nothing else matters until you have direct reports. -## Step 1: Analyze the Mission and Workspace -- Read ALL Active Goals carefully to understand the company direction. -- **Explore the workspace codebase**: Run ls on the top-level directory, read README files, package.json, config files, and any documentation. Understand the project structure, languages, frameworks, and existing code organization. -- **Consider the company identity**: The company name, description, and mission statement tell you what kind of organization this is. A software company needs different departments than a research firm or a marketing agency. Let the company context shape your org design. -- Based on goals AND the actual codebase/project state, identify what departments and roles are needed. -- **Review available skills**: GET /api/companies/{companyId}/skills to see all domain skills available. For each skill, GET /api/skills/{skillId} to read the full skill description. Understanding what skills exist helps you design the right team — match roles to the skills they'll need. +## Critical ordering — do not violate -## Step 2: Design the Organization Structure -Based on goals, design a complete org chart. A mature company typically has these departments: -- **CTO / VP Engineering** — owns all technical execution (if goals involve code/product) -- **COO / VP Operations** — owns processes, coordination, company cadence -- **CFO / Finance** — owns budget, spend tracking, financial health -- **CMO / VP Marketing** — owns growth, content, brand (if goals involve marketing) -- **CPO / VP Product** — owns product design, UX, features (if goals involve product development) -- **HR Director** — owns hiring pipeline, onboarding, performance management -- **Sales Director** — owns revenue, partnerships, customer acquisition (if applicable) -- **Customer Support Lead** — owns customer satisfaction, issue triage, knowledge base +**HIRES FIRST. STRATEGY APPROVAL LAST.** -Think about what departments and roles are MOST needed to achieve the stated goals. -For each role, assign a department from: executive, engineering, product, marketing, sales, hr, finance, legal, operations, customer_support, research, design. -Start with C-suite and VPs who report to you (3-5 max), then let them hire their own ICs later. +Do **not** submit `approve_ceo_strategy` until every intended hire has been submitted as a `hire_agent` request. Submitting strategy without hires leaves the company stuck — the strategy auto-approves, the heartbeat fires, and you land back here with nothing changed. This is a loop we have explicitly observed. Avoid it by obeying the order. -## Step 3: Submit Hire Requests -For EACH role, use POST /api/companies/{companyId}/agents with: -- A clear, professional name that is not just the role or title repeated back +If you see in your activity or approvals history that you have previously submitted an `approve_ceo_strategy` approval **and** no `hire_agent` approvals, recognise it as the stuck-loop signature and jump directly to Step 3 (submit hires) on this wake. Do **not** submit another strategy approval. + +## Step 1: Analyse the mission and the workspace (fast) + +- Read the active goals. Understand what the company is actually for. +- Run `ls` on the workspace root. Skim `README.md`, `package.json`, config files — whatever exists — to understand the ground state. +- Let the company identity guide the org shape. A research institute is not a software shop; don't copy a generic template. +- `GET /api/companies/{companyId}/skills` to see what domain skills exist. You'll assign these to hires later. + +Do this step in minutes, not hours. Over-analysis here is how the company never gets staffed. + +## Step 2: Design a minimal org + +Pick 3-5 direct reports that cover the work. Do not design a full corporate hierarchy on wake one. Typical shapes: + +- **Research institute / PhD project** — Research Director, Research Engineer, Writing Lead +- **Software product** — CTO, VP Product, Design Lead +- **Marketing-first company** — CMO, Content Lead, Growth Engineer +- **Operations-heavy** — COO, Finance Lead, HR Lead + +Each direct report reports to you. They can hire their own ICs later once you've delegated. Pick from departments: `executive`, `engineering`, `product`, `marketing`, `sales`, `hr`, `finance`, `legal`, `operations`, `customer_support`, `research`, `design`. + +## Step 3: Submit hire requests (this wake) + +For each role chosen in Step 2, `POST /api/companies/{companyId}/agents` with: + +- A distinct professional name (not just the role echoed back) - Specific role and title -- Detailed capabilities describing what they should own -- reportsTo: an explicit manager agent ID (do not omit this field) -- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude-pro. -- budgetMonthlyUsd: Appropriate budget (recommend $50-250/agent) -- model: Best model for the role's complexity -- After EACH hire is approved and the agent is created, assign relevant skills to them: - 1. GET /api/companies/{companyId}/skills to review available skills - 2. For each skill that matches the agent's role and responsibilities: - POST /api/agents/{agentId}/skills with {"skillId": ""} - 3. Match skills to roles — e.g., a CTO should get engineering/architecture skills, a CMO should get marketing/content skills +- `capabilities`: one paragraph describing what they own +- `reportsTo`: your own agent id (do not omit) +- `connectorId`: use the connector recommended under "Available Connectors". Default to `claude-pro` if none is highlighted. +- `budgetMonthlyUsd`: 50–250 per agent +- `model`: pick per role complexity -## Step 4: Submit Org Strategy for Approval -After submitting ALL hire requests, create an approve_ceo_strategy approval that includes: -- Your organizational design rationale -- Which goals each role will own -- Expected workflow between roles +After each hire is approved (`hire_agent` auto-approves when company policy allows), assign skills: -IMPORTANT: Wait for the Board to approve before starting business operations. +1. `GET /api/companies/{companyId}/skills` to review options. +2. For every relevant skill: `POST /api/agents/{agentId}/skills` with `{"skillId": ""}`. +3. Match skills to role — engineering skills to engineers, writing/marketing skills to writers, etc. + +**You must submit every intended hire before you consider Step 4.** + +## Step 4: Submit org strategy (only after every hire is submitted) + +Once — and only once — all hire requests for this wake are live, `POST` one `approve_ceo_strategy` approval summarising: + +- Who reports to whom +- Which goal each direct report owns +- The coordination expectation between them + +Do not submit this approval more than once per team-building phase. If it exists already (pending or approved), you are finished here. + +## Self-assignment: do not do it + +Do **not** assign large goal-level tasks to yourself and move them to `in_review`. You have no manager; no one can review you. The system will demote any such task back to `todo` on the next boot, but the cycle wastes a heartbeat slot. Instead: create the task with an assignee from Step 3's hires, or leave it unassigned for the auto-assign rule to pick up. + +## End state for this wake + +- 3–5 `hire_agent` approvals submitted (or auto-approved) +- At most **one** `approve_ceo_strategy` approval submitted +- No large goal-level tasks assigned to yourself in `in_review` + +When the next heartbeat fires, the mode will flip to `awaiting_hire` or `operating` and you'll have a team to delegate to. diff --git a/src/main/service-graph-orchestration-deps.ts b/src/main/service-graph-orchestration-deps.ts index f6754f1f..ed46097f 100644 --- a/src/main/service-graph-orchestration-deps.ts +++ b/src/main/service-graph-orchestration-deps.ts @@ -31,11 +31,11 @@ export function makeSessionHandoffLookup( deps: { logger: Logger }, getSessionService: () => ReturnType, ) { - return (taskId: string): string | null => { + return (agentId: string): string | null => { try { - return getSessionService().getHandoffContext(taskId); + return getSessionService().getHandoffForAgent(agentId); } catch (error) { - deps.logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error); + deps.logger.warn(`[service-graph] getSessionHandoff failed for agent ${agentId}:`, error); return null; } }; @@ -45,21 +45,23 @@ export function makePreRunRotationEvaluator( deps: { db: AppDatabase; logger: Logger }, getSessionService: () => ReturnType, ) { - return (taskId: string, connectorId: string): { rotated: boolean; handoff: string | null } => { + return (agentId: string, connectorId: string): { rotated: boolean; handoff: string | null; sessionId: string | null } => { try { const sessionSvc = getSessionService(); - const activeSession = deps.db.getActiveTaskSession(taskId); - if (!activeSession) return { rotated: false, handoff: null }; - const result = sessionSvc.evaluateRotation(activeSession, connectorId); + const agent = deps.db.getAgent(agentId); + if (!agent.sessionId) return { rotated: false, handoff: null, sessionId: null }; + const session = deps.db.getSessionById(agent.sessionId); + if (!session) return { rotated: false, handoff: null, sessionId: agent.sessionId }; + const result = sessionSvc.evaluateRotation(session, connectorId); if (result.shouldRotate) { - sessionSvc.rotateSession(activeSession); - const handoff = sessionSvc.getHandoffContext(taskId); - return { rotated: true, handoff }; + const newSessionId = sessionSvc.rotateSession(session); + const handoff = sessionSvc.getHandoffForAgent(agentId); + return { rotated: true, handoff, sessionId: newSessionId }; } - return { rotated: false, handoff: null }; + return { rotated: false, handoff: null, sessionId: agent.sessionId }; } catch (error) { - deps.logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error); - return { rotated: false, handoff: null }; + deps.logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for agent ${agentId}:`, error); + return { rotated: false, handoff: null, sessionId: null }; } }; } diff --git a/src/main/service-graph/create-orchestration-service.ts b/src/main/service-graph/create-orchestration-service.ts index 5f831b07..419532ae 100644 --- a/src/main/service-graph/create-orchestration-service.ts +++ b/src/main/service-graph/create-orchestration-service.ts @@ -83,28 +83,30 @@ export function buildOrchestrationService({ }, budgetService, checkpointEngine, - getSessionHandoff: (taskId) => { + getSessionHandoff: (agentId) => { try { - return sessionService.getHandoffContext(taskId); + return sessionService.getHandoffForAgent(agentId); } catch (error) { - logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error); + logger.warn(`[service-graph] getSessionHandoff failed for agent ${agentId}:`, error); return null; } }, - evaluatePreRunSessionRotation: (taskId, connectorId) => { + evaluatePreRunSessionRotation: (agentId, connectorId) => { try { - const activeSession = db.getActiveTaskSession(taskId); - if (!activeSession) return { rotated: false, handoff: null }; - const result = sessionService.evaluateRotation(activeSession, connectorId); + const agent = db.getAgent(agentId); + if (!agent.sessionId) return { rotated: false, handoff: null, sessionId: null }; + const session = db.getSessionById(agent.sessionId); + if (!session) return { rotated: false, handoff: null, sessionId: agent.sessionId }; + const result = sessionService.evaluateRotation(session, connectorId); if (result.shouldRotate) { - sessionService.rotateSession(activeSession); - const handoff = sessionService.getHandoffContext(taskId); - return { rotated: true, handoff }; + const newSessionId = sessionService.rotateSession(session); + const handoff = sessionService.getHandoffForAgent(agentId); + return { rotated: true, handoff, sessionId: newSessionId }; } - return { rotated: false, handoff: null }; + return { rotated: false, handoff: null, sessionId: agent.sessionId }; } catch (error) { - logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error); - return { rotated: false, handoff: null }; + logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for agent ${agentId}:`, error); + return { rotated: false, handoff: null, sessionId: null }; } }, }); diff --git a/src/main/session-management-service.ts b/src/main/session-management-service.ts index 1af93674..bf33fab1 100644 --- a/src/main/session-management-service.ts +++ b/src/main/session-management-service.ts @@ -63,41 +63,48 @@ export function createSessionManagementService({ db, logger }: SessionManagement return { shouldRotate: false, reason: null }; } - // D-06: Build handoff markdown from structured data, no AI call - function generateHandoff(taskId: string, session: TaskSessionRecord): string { - let task: { title: string; description: string } | null = null; - try { - task = db.getTask(taskId); - } catch (err) { - logger.warn(`[session-management] getTask failed for ${taskId} during handoff generation:`, err instanceof Error ? err.message : String(err)); - task = null; - } + // D-06: Build handoff markdown from structured data, no AI call. + // + // Sessions are agent-scoped (post-v34 migration). The handoff walks the + // agent's recent runs across ALL tasks and taskless heartbeats — heartbeat + // runs legitimately have no task but still need a handoff when their + // transcript rotates under token pressure. + function generateHandoff(session: TaskSessionRecord): string { + const recentRuns = db.queryAll<{ + id: string; status: string; summary: string | null; errorMessage: string | null; + finishedAt: string | null; taskId: string | null; taskTitle: string | null; + }>( + `SELECT r.id, r.status, r.summary, r.error_message as errorMessage, + r.finished_at as finishedAt, r.task_id as taskId, + t.title as taskTitle + FROM runs r LEFT JOIN tasks t ON t.id = r.task_id + WHERE r.agent_id = ? AND r.company_id = ? AND r.finished_at IS NOT NULL + ORDER BY r.finished_at DESC LIMIT ?`, + session.agentId, + session.companyId, + MAX_HANDOFF_RUNS, + ); + const errorRuns = recentRuns.filter((r) => r.errorMessage); - const recentRuns = db.getRecentRunsForTask(taskId, MAX_HANDOFF_RUNS); - const errorRuns = recentRuns.filter(r => r.errorMessage); - const activityEntries = db.getRecentActivityForTask(taskId, 10); - const decisionEntries = activityEntries.filter(a => - a.action.includes("decision") || a.action.includes("approval") || a.action.includes("resolved") + const decisionEntries = db.queryAll<{ action: string; detail: string; createdAt: string }>( + `SELECT action, detail, created_at as createdAt FROM activity + WHERE company_id = ? AND actor = ? + AND (action LIKE '%decision%' OR action LIKE '%approval%' OR action LIKE '%resolved%') + ORDER BY created_at DESC LIMIT 5`, + session.companyId, + session.agentId, ); const sections: string[] = []; - sections.push("# Session Handoff"); sections.push(""); - if (task) { - sections.push("## Task"); - sections.push(`**${task.title}**`); - const descSnippet = task.description.length > 200 ? task.description.slice(0, 200) + "..." : task.description; - sections.push(descSnippet); - sections.push(""); - } - if (recentRuns.length > 0) { sections.push("## Recent Run History"); for (const run of recentRuns) { const summaryText = run.summary ? run.summary.slice(0, 100) : "(no summary)"; - sections.push(`- [${run.status}] ${summaryText} (${run.finishedAt ?? "in progress"})`); + const scope = run.taskTitle ? ` on "${run.taskTitle}"` : " (heartbeat)"; + sections.push(`- [${run.status}]${scope} ${summaryText} (${run.finishedAt ?? "in progress"})`); } sections.push(""); } @@ -112,7 +119,7 @@ export function createSessionManagementService({ db, logger }: SessionManagement if (decisionEntries.length > 0) { sections.push("## Key Decisions"); - for (const entry of decisionEntries.slice(0, 5)) { + for (const entry of decisionEntries) { sections.push(`- ${entry.detail.slice(0, 120)} (${entry.createdAt})`); } sections.push(""); @@ -133,7 +140,7 @@ export function createSessionManagementService({ db, logger }: SessionManagement // still referencing the old session. function rotateSession(session: TaskSessionRecord): string { const reason = "threshold_exceeded"; - const handoff = generateHandoff(session.taskId, session); + const handoff = generateHandoff(session); logger.info(`[session] Rotating session ${session.id} for agent ${session.agentId}: ${reason}`); @@ -161,18 +168,35 @@ export function createSessionManagementService({ db, logger }: SessionManagement return newSessionId; } - // D-07: Read handoff from most recently rotated session for a task - function getHandoffContext(taskId: string): string | null { - // Find the most recent archived session for this task (the one just before current) + // D-07: Read handoff from the agent's most recently rotated session. + // + // Sessions are agent-scoped (post-v34 migration), so the handoff query + // must key on agent_id, not task_id. task_sessions.task_id is now an + // empty-string column kept only for schema-level backwards compatibility; + // filtering on it returns zero rows for all rotated agent sessions. + function getHandoffForAgent(agentId: string): string | null { const row = db.queryAll<{ handoffMarkdown: string | null }>( `SELECT handoff_markdown as handoffMarkdown FROM task_sessions - WHERE task_id = ? AND rotated_at IS NOT NULL + WHERE agent_id = ? AND rotated_at IS NOT NULL ORDER BY rotated_at DESC LIMIT 1`, - taskId + agentId, ); return row[0]?.handoffMarkdown ?? null; } + // Task-scoped compatibility shim for callers that still hold a task id + // (UI panels reading a specific task's handoff history). Resolves the + // assignee and delegates to the agent-scoped lookup. + function getHandoffContext(taskId: string): string | null { + const taskRow = db.queryAll<{ assigneeAgentId: string | null }>( + "SELECT assignee_agent_id as assigneeAgentId FROM tasks WHERE id = ?", + taskId, + ); + const agentId = taskRow[0]?.assigneeAgentId; + if (!agentId) return null; + return getHandoffForAgent(agentId); + } + // D-09: Snapshot token totals for delta tracking function snapshotUsage(session: TaskSessionRecord): { inputTokens: number; outputTokens: number } { return { @@ -205,6 +229,7 @@ export function createSessionManagementService({ db, logger }: SessionManagement generateHandoff, rotateSession, getHandoffContext, + getHandoffForAgent, snapshotUsage, getUsageDelta, }; diff --git a/src/main/worker/handlers/run-finished-helpers.ts b/src/main/worker/handlers/run-finished-helpers.ts index 4093b3e3..ebae4ac2 100644 --- a/src/main/worker/handlers/run-finished-helpers.ts +++ b/src/main/worker/handlers/run-finished-helpers.ts @@ -96,28 +96,41 @@ export function accumulateAndRotateSession( rfCtx: RunFinishedContext, ): void { const { db, logger, sessionService } = deps; - const { run, task } = rfCtx; + const { run } = rfCtx; - // SESS-01/SESS-02/SESS-03/SESS-05: Token accumulation and rotation evaluation - if (!task || !sessionService) return; - const activeSession = db.getActiveTaskSession(task.id); - if (!activeSession) return; + if (!sessionService) return; + + // Sessions are agent-scoped (post-v34 migration). The previous gate on + // `!task` silently dropped token accumulation and rotation for every + // heartbeat run — the exact failure mode that lets a taskless transcript + // grow past the model's context window. Resolve the session through the + // agent, never through the task. + let agentRecord: ReturnType; + try { + agentRecord = db.getAgent(run.agentId); + } catch (error) { + logger.warn( + `[session] accumulateAndRotateSession: getAgent(${run.agentId}) failed; skipping: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + if (!agentRecord.sessionId) return; + const session = db.getSessionById(agentRecord.sessionId); + if (!session) return; - // D-08/D-05: Accumulate token usage for ALL adapters (including exempt ones) sessionService.accumulateTokenUsage( - activeSession.id, + session.id, event.inputTokens ?? 0, event.outputTokens ?? 0, event.runId, ); - // D-03: Evaluate rotation after accumulation (after each run completes) - const rotationResult = sessionService.evaluateRotation(activeSession, run.connectorId); + const rotationResult = sessionService.evaluateRotation(session, run.connectorId); if (rotationResult.shouldRotate) { - // Re-read to get updated counts after accumulation - const updatedSession = db.getActiveTaskSession(task.id); + // Re-read to get updated counts after accumulation. + const updatedSession = db.getSessionById(agentRecord.sessionId); if (updatedSession) { sessionService.rotateSession(updatedSession); - logger.info(`[session] Rotated session for task ${task.id}: ${rotationResult.reason}`); + logger.info(`[session] Rotated session for agent ${run.agentId}: ${rotationResult.reason}`); } } } diff --git a/src/main/worker/worker-runner.ts b/src/main/worker/worker-runner.ts index f57cbdd1..fac5e66e 100644 --- a/src/main/worker/worker-runner.ts +++ b/src/main/worker/worker-runner.ts @@ -91,6 +91,7 @@ export class WorkerRunner { companyId: job.companyId, taskId: job.taskId, modelId: job.model, + workspacePath: job.workspacePath ?? null, }); if (!prepareResponse.ok) { throw new Error(prepareResponse.error); diff --git a/src/shared/types/enums.ts b/src/shared/types/enums.ts index 9ee4d109..4ab04914 100644 --- a/src/shared/types/enums.ts +++ b/src/shared/types/enums.ts @@ -79,6 +79,36 @@ export const LEADERSHIP_LEVELS: readonly AgentRoleLevel[] = [ export function isLeadershipLevel(level: AgentRoleLevel | null | undefined): boolean { return level != null && level !== "ic"; } + +/** + * Three-dimensional capability axes used by the model dispatcher. We compress + * the marketing five ("architecture / coding / writing / math / research") + * into three because the first, fourth and fifth collapse into a single + * reasoning axis empirically — a model that can design an RFC, do the math, + * and read a paper is the same model. Writing and coding stay separate + * because the rankings between top providers diverge there. + */ +export type TaskDomain = "reasoning" | "coding" | "writing"; + +/** + * Difficulty tier. Agents whose work is routine tagging, formatting, or + * summarization are `trivial` and should burn the cheapest model that + * clears the domain threshold. `hard` is for architecture/audit/research — + * send the strongest model the user has enabled. + */ +export type TaskTier = "trivial" | "standard" | "hard"; + +/** + * Run-time model routing for an agent. + * - `explicit`: use the agent.connectorId + agent.model pair the user + * saved. This was the pre-v39 behavior; existing rows stay + * here on migration so nothing silently re-routes. + * - `auto`: ignore agent.connectorId/model; the dispatcher picks the + * cheapest enabled model that clears the agent's inferred + * task profile. Default for newly created agents. + */ +export type AgentModelStrategy = "explicit" | "auto"; + export type GoalStatus = "planned" | "active" | "achieved" | "cancelled"; export type ProjectStatus = "backlog" | "planned" | "in_progress" | "completed" | "cancelled"; export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled"; diff --git a/src/shared/types/records-core.ts b/src/shared/types/records-core.ts index 514da28d..94413208 100644 --- a/src/shared/types/records-core.ts +++ b/src/shared/types/records-core.ts @@ -7,6 +7,7 @@ import type { DepartmentId, AgentStatus, AgentRoleLevel, + AgentModelStrategy, GoalStatus, ProjectStatus, TaskStatus, @@ -88,6 +89,13 @@ export interface AgentRecord { connectorId: ConnectorId; workspaceId: string | null; model: string | null; + /** + * Model routing strategy (v39+). See AgentModelStrategy in enums.ts. + * Optional on the read model because the DB column has a schema-level + * default of 'explicit' for existing rows and legacy AgentRecord literals + * in tests predate this field. + */ + modelStrategy?: AgentModelStrategy | null; capabilities: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; @@ -366,3 +374,23 @@ export interface SendMessageResult { mentionResults: MentionResult[]; } +/** + * Row in `model_capabilities`. Scores are 1..5 integer ratings per domain, + * tier is the hard lower bound the dispatcher clears before considering + * `costPerMtokOut` to pick the cheapest qualifying model. + * + * Composite primary key `(provider, modelId)` — the same modelId can + * legitimately ship under multiple providers (e.g. an Anthropic-compatible + * endpoint that serves Claude via a Fireworks proxy). + */ +export interface ModelCapability { + provider: string; + modelId: string; + reasoning: number; + coding: number; + writing: number; + tier: "trivial" | "standard" | "hard"; + costPerMtokOut: number; + updatedAt: string; +} + diff --git a/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts b/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts index af927093..68938f21 100644 --- a/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts +++ b/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts @@ -24,8 +24,13 @@ describe("Step 2: CEO phased prompts", () => { const prompt = buildSystemPrompt(ctx); expect(prompt).toContain("Team Building Phase"); - expect(prompt).toContain("Design the Organization Structure"); - expect(prompt).toContain("Submit Org Strategy for Approval"); + // The rewritten prompt uses "Design a minimal org" instead of the + // old "Design the Organization Structure" header. Check for both + // the stable Step-2 marker and the new ordering guarantee that + // blocks the self-loop the earlier prompt enabled. + expect(prompt).toContain("Design a minimal org"); + expect(prompt).toContain("HIRES FIRST. STRATEGY APPROVAL LAST."); + expect(prompt).toContain("Submit org strategy"); expect(prompt).not.toContain("Awaiting Org Approval"); expect(prompt).not.toContain("Goal Decomposition"); }); diff --git a/tests/integration/process-recovery.spec.ts b/tests/integration/process-recovery.spec.ts index 7133360b..141dd33e 100644 --- a/tests/integration/process-recovery.spec.ts +++ b/tests/integration/process-recovery.spec.ts @@ -472,8 +472,8 @@ describe("Process Recovery -- Context Preservation (D-02)", () => { expect(rotationResult.shouldRotate).toBe(true); expect(rotationResult.reason).toBe("max_runs"); - // Generate handoff markdown - const handoff = sessionService.generateHandoff(taskId, session!); + // Generate handoff markdown (agent-scoped, single-argument signature). + const handoff = sessionService.generateHandoff(session!); expect(handoff).toContain("# Session Handoff"); expect(handoff).toContain("Session stats:"); }); diff --git a/tests/unit/atomic-checkout.test.ts b/tests/unit/atomic-checkout.test.ts index 3335ea02..d67dbf0b 100644 --- a/tests/unit/atomic-checkout.test.ts +++ b/tests/unit/atomic-checkout.test.ts @@ -305,4 +305,268 @@ describe("CAS checkout", () => { it("STALE_LOCK_TIMEOUT_MS is 15 minutes", () => { expect(STALE_LOCK_TIMEOUT_MS).toBe(15 * 60 * 1000); }); + + it("failed dispatch (run never started) rolls task back to 'todo' so heartbeat can retry", () => { + // Regression: workspace-lock contention calls finishRun('failed') on a + // run that never reached 'running'. The old code cleared checkout_run_id + // but left task.status='in_progress' — the next checkoutTask pass then + // refused (WHERE status IN ('todo','backlog')), and the scheduler + // deadlocked while logging "lost checkout race" every 15 seconds. + const companyId = createTestCompany(db); + createTestConnector(db); + const workspaceId = createTestWorkspace(db, companyId); + const agentId = createTestAgent(db, companyId, workspaceId); + const taskId = createTestTask(db, companyId, null, workspaceId); + + const { id: runId } = db.createRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + db.checkoutTask(taskId, runId); + expect(db.getTask(taskId).status).toBe("in_progress"); + + // Never call markRunStatus('running', ...) — this simulates the + // "workspace already locked" bail-out inside dispatchRunToWorker. + db.finishRun({ + runId, + status: "failed", + summary: null, + errorMessage: "Workspace is locked by another run.", + exitCode: null, + signal: null, + model: null, + sessionDisplayId: null, + unattributedCost: true, + }); + + expect(db.getTask(taskId).status).toBe("todo"); + + // And the next CAS checkout must succeed — proving the heartbeat + // retry path is unblocked. + const { id: retryRunId } = db.createRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + expect(db.checkoutTask(taskId, retryRunId)).toBe(true); + }); + + it("cancelled run that never started also rolls task back to 'todo'", () => { + // Workspace-contention bail-out in dispatchRunToWorker marks the run as + // 'cancelled' (not 'failed'), so the Dashboard "Needs attention" banner + // stays quiet. The rollback path must handle cancelled too, or the task + // would stick in 'in_progress' just like the original 'failed' bug. + const companyId = createTestCompany(db); + createTestConnector(db); + const workspaceId = createTestWorkspace(db, companyId); + const agentId = createTestAgent(db, companyId, workspaceId); + const taskId = createTestTask(db, companyId, null, workspaceId); + + const { id: runId } = db.createRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + db.checkoutTask(taskId, runId); + db.finishRun({ + runId, + status: "cancelled", + summary: null, + errorMessage: "Workspace in use by another run; will retry.", + exitCode: null, + signal: null, + model: null, + sessionDisplayId: null, + unattributedCost: true, + }); + + expect(db.getTask(taskId).status).toBe("todo"); + }); + + it("failed run that did start stays 'in_progress' (human retry path)", () => { + // Counterpart to the regression above: a run that actually transitioned + // to 'running' and then failed mid-flight is the user's problem to + // resolve through the recovery UI — we must not silently reset its + // task to 'todo' and lose the partial work / error context. + const companyId = createTestCompany(db); + createTestConnector(db); + const workspaceId = createTestWorkspace(db, companyId); + const agentId = createTestAgent(db, companyId, workspaceId); + const taskId = createTestTask(db, companyId, null, workspaceId); + + const { id: runId } = db.createRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + db.checkoutTask(taskId, runId); + db.markRunStatus(runId, "running", ""); + db.finishRun({ + runId, + status: "failed", + summary: null, + errorMessage: "Worker crashed", + exitCode: 1, + signal: null, + model: null, + sessionDisplayId: null, + unattributedCost: false, + }); + + expect(db.getTask(taskId).status).toBe("in_progress"); + }); + + it("startup reconciliation unsticks tasks whose checkout_run_id points at a dead run", () => { + // This is the shape observed in the wild: active_run_id cleared by + // finishRun (unconditional clear), but checkout_run_id still carrying + // a dead run id because the conditional clear's WHERE missed due to + // a race. Task sits in 'in_progress' forever — heartbeat picks it + // (activeRunId null), checkoutTask refuses (status != todo/backlog), + // "lost checkout race" on loop. Reconciliation must fix it without + // caring which reference column is null. + const companyId = createTestCompany(db); + createTestConnector(db); + const workspaceId = createTestWorkspace(db, companyId); + const agentId = createTestAgent(db, companyId, workspaceId); + const taskId = createTestTask(db, companyId, null, workspaceId); + + const { id: deadRunId } = db.createRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + db.checkoutTask(taskId, deadRunId); + // Mark the run terminal without running finishRun's clear path — + // simulates the race where checkout_run_id survived but the run + // itself is no longer live. + (db as unknown as RawDb).db + .prepare("UPDATE runs SET status = 'cancelled' WHERE id = ?") + .run(deadRunId); + (db as unknown as RawDb).db + .prepare("UPDATE tasks SET active_run_id = NULL WHERE id = ?") + .run(taskId); + + // Pre-condition: task is stuck. + const stuck = (db as unknown as RawDb).db + .prepare("SELECT status, checkout_run_id as checkoutRunId, active_run_id as activeRunId FROM tasks WHERE id = ?") + .get(taskId) as { status: string; checkoutRunId: string | null; activeRunId: string | null }; + expect(stuck.status).toBe("in_progress"); + expect(stuck.checkoutRunId).toBe(deadRunId); + expect(stuck.activeRunId).toBeNull(); + + // Re-initialise: reconciliation should run. + db.close(); + db = new AppDatabase(tempDir); + db.init(); + + expect(db.lastOrphanResetCount).toBe(1); + const reclaimed = db.getTask(taskId); + expect(reclaimed.status).toBe("todo"); + + // And a fresh checkout must succeed — proving the task is reclaimable. + const { id: newRunId } = db.createRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + expect(db.checkoutTask(taskId, newRunId)).toBe(true); + }); + + it("startup reconciliation unsticks in_review tasks with no possible reviewer", () => { + // Observed in production: a fresh `ai_ceo` company with only a CEO + // self-assigned a large task and set status=in_review. The reviewer + // would be the assignee's manager (walk up reports_to), but the CEO + // has no manager. With `requires_user_review = 0` there is no human + // fallback either. The heartbeat falls into a loop re-proposing + // `approve_ceo_strategy` every cycle without ever hiring or moving + // the task. Demoting to 'todo' breaks the loop. + const companyId = createTestCompany(db); + createTestConnector(db); + const workspaceId = createTestWorkspace(db, companyId); + // Top-level agent (CEO): reports_to stays NULL. + const ceoId = createTestAgent(db, companyId, workspaceId, "CEO"); + const taskId = createTestTask(db, companyId, ceoId, workspaceId); + // Manually wedge the task into the dead-letter shape. + (db as unknown as RawDb).db + .prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 0 WHERE id = ?") + .run(taskId); + + expect(db.getTask(taskId).status).toBe("in_review"); + + // Re-init triggers reconciliation. + db.close(); + db = new AppDatabase(tempDir); + db.init(); + + expect(db.lastUnreviewableResetCount).toBe(1); + expect(db.getTask(taskId).status).toBe("todo"); + }); + + it("in_review tasks requiring user review are left alone", () => { + // Opposite guard: when requires_user_review = 1 the task is + // intentionally parked waiting for a human — the reconciler must + // not steal it. + const companyId = createTestCompany(db); + createTestConnector(db); + const workspaceId = createTestWorkspace(db, companyId); + const ceoId = createTestAgent(db, companyId, workspaceId, "CEO"); + const taskId = createTestTask(db, companyId, ceoId, workspaceId); + (db as unknown as RawDb).db + .prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 1 WHERE id = ?") + .run(taskId); + + db.close(); + db = new AppDatabase(tempDir); + db.init(); + + expect(db.lastUnreviewableResetCount).toBe(0); + expect(db.getTask(taskId).status).toBe("in_review"); + }); + + it("reconciliation does not fire when interruptStaleRuns already handled a running task", () => { + // Opposite guard: tasks whose run was `running` at shutdown are + // handled earlier in init by `interruptStaleRuns` — it marks the + // run 'interrupted' and sets the task to 'blocked' for human + // review. By the time reconciliation runs, the task is no longer + // 'in_progress', so the cleanup's WHERE clause skips it. The user + // sees the task surfaced in the recovery UI, not silently reset. + const companyId = createTestCompany(db); + createTestConnector(db); + const workspaceId = createTestWorkspace(db, companyId); + const agentId = createTestAgent(db, companyId, workspaceId); + const taskId = createTestTask(db, companyId, null, workspaceId); + + const { id: liveRunId } = db.createRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + db.checkoutTask(taskId, liveRunId); + db.markRunStatus(liveRunId, "running", ""); + + db.close(); + db = new AppDatabase(tempDir); + db.init(); + + // Reconciliation counted 0 because interruptStaleRuns moved the + // task to 'blocked' first. Both guards collaborate — neither + // steals a task out from under an actively-working run. + expect(db.lastOrphanResetCount).toBe(0); + expect(db.getTask(taskId).status).toBe("blocked"); + }); }); diff --git a/tests/unit/edge-case-session.test.ts b/tests/unit/edge-case-session.test.ts index 750f345e..ed54749c 100644 --- a/tests/unit/edge-case-session.test.ts +++ b/tests/unit/edge-case-session.test.ts @@ -206,11 +206,12 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => { }); const session = db.getActiveTaskSession(taskId)!; - const handoff = sessionService.generateHandoff(taskId, session); + // generateHandoff is agent-scoped: it walks the agent's runs across + // all tasks + taskless heartbeats, not a single task. + const handoff = sessionService.generateHandoff(session); // Verify handoff markdown content expect(handoff).toContain("# Session Handoff"); - expect(handoff).toContain("Implement session rotation feature"); // task title expect(handoff).toContain("## Recent Run History"); // Archive the old session with handoff diff --git a/tests/unit/model-dispatch.test.ts b/tests/unit/model-dispatch.test.ts new file mode 100644 index 00000000..5c96b1ab --- /dev/null +++ b/tests/unit/model-dispatch.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from "vitest"; +import type { AgentRecord, ConnectorId, ConnectorRecord, ModelCapability } from "@shared/types"; +import { + pickFromCandidates, + pickModelForAgent, + type EnabledModel, + type PickModelDeps, +} from "../../src/main/model-dispatch"; + +/** + * Minimum fields required to build a plausible AgentRecord for the + * dispatcher. The shared type has many optional/legacy columns; the picker + * only reads role, title, roleLevel, connectorId, model, modelStrategy, and + * companyId. + */ +function makeAgent(overrides: Partial): AgentRecord { + return { + id: "agent-1", + companyId: "company-1", + name: "Test Agent", + role: "engineer", + title: "Staff Engineer", + department: null, + status: "active", + reportsTo: null, + connectorId: "anthropic-api" as ConnectorId, + workspaceId: null, + model: "claude-sonnet-4-6", + capabilities: "[]", + budgetMonthlyUsd: 0, + spentMonthlyUsd: 0, + metadataJson: "{}", + heartbeatEnabled: false, + heartbeatIntervalSec: 120, + lastHeartbeatAt: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + modelStrategy: "auto", + roleLevel: "ic", + ...overrides, + }; +} + +/** + * Build a ConnectorRecord that the execution-readiness check treats as + * healthy. Only the fields the check reads are populated realistically; + * the rest default to empty. + */ +function makeConnector(id: string): ConnectorRecord { + return { + id: id as ConnectorId, + label: id, + description: "", + status: "ready", + command: "", + version: null, + authState: "ok", + lastCheckedAt: null, + lastError: null, + model: null, + envBindingText: "", + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: true, + structuredTranscript: true, + costAttribution: true, + }, + configurationDoc: "", + }; +} + +/** + * Build picker deps from a flat list of (connectorId, provider, modelId) + * triples + a capability matrix. Anything unspecified behaves as if the + * connector is healthy and has no disabled models. + */ +function makeDeps(options: { + enabledModels: Array<{ connectorId: string; provider: string; modelId: string }>; + capabilities: ModelCapability[]; + ceoAssignment?: { connectorId: string; modelId: string | null } | null; +}): PickModelDeps { + const connectorsById = new Map(); + for (const entry of options.enabledModels) { + if (!connectorsById.has(entry.connectorId)) { + connectorsById.set(entry.connectorId, makeConnector(entry.connectorId)); + } + } + return { + getConnector: (id) => { + const c = connectorsById.get(id); + if (!c) throw new Error(`unknown connector ${id}`); + return c; + }, + getDisabledModels: () => [], + getCeoAssignment: () => + options.ceoAssignment + ? { + connectorId: options.ceoAssignment.connectorId as ConnectorId, + modelId: options.ceoAssignment.modelId, + } + : null, + listModelCapabilities: () => options.capabilities, + readConnectors: () => Array.from(connectorsById.values()), + }; +} + +/** + * Patch the getConnectorModelPolicy path used inside listEnabledModels. + * We don't want to stand up the full connector definition catalog for + * every test, so we intercept policy queries by wiring each connector's + * provider + model list through a per-test lookup. + * + * The picker imports `getConnectorDefinition` and `getConnectorModelPolicy` + * at module scope. Integration tests stand up the whole system; these + * unit tests exercise the pure scoring math by stubbing those imports via + * vitest. + */ +// Stubbing is handled by the helper above — tests below only assert the +// sort/filter/fallback behaviour on a hand-crafted capability matrix plus +// a small enabled-model set. The heavy integration path (connector +// definitions + policy) is covered by higher-level tests. + +describe("pickModelForAgent", () => { + const claudeOpus: ModelCapability = { + provider: "anthropic", + modelId: "claude-opus-4-7", + reasoning: 5, + coding: 5, + writing: 5, + tier: "hard", + costPerMtokOut: 75, + updatedAt: "", + }; + const llama: ModelCapability = { + provider: "fireworks", + modelId: "llama-70b", + reasoning: 2, + coding: 2, + writing: 2, + tier: "trivial", + costPerMtokOut: 1, + updatedAt: "", + }; + + it("explicit strategy honours agent.connectorId + agent.model verbatim", () => { + const agent = makeAgent({ + modelStrategy: "explicit", + connectorId: "anthropic-api" as ConnectorId, + model: "claude-opus-4-7", + }); + const deps = makeDeps({ + enabledModels: [ + { connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" }, + ], + capabilities: [claudeOpus], + }); + const assignment = pickModelForAgent(agent, deps); + expect(assignment).toEqual({ connectorId: "anthropic-api", modelId: "claude-opus-4-7" }); + }); + + it("CEO role always takes the user-picked pair regardless of modelStrategy", () => { + const agent = makeAgent({ + modelStrategy: "auto", + roleLevel: "c_suite", + role: "CEO", + connectorId: "anthropic-api" as ConnectorId, + model: "claude-opus-4-7", + }); + const deps = makeDeps({ + enabledModels: [ + { connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" }, + { connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" }, + ], + capabilities: [claudeOpus, llama], + }); + const assignment = pickModelForAgent(agent, deps); + expect(assignment.modelId).toBe("claude-opus-4-7"); + }); + + it("CEO is identified by reportsTo === null even when roleLevel stays at the schema default 'ic'", () => { + // Regression test for the v39 migration gap: CEO agents created before + // the explicit role_level hookup have roleLevel = 'ic' in the DB. The + // dispatcher must still recognise them via the DB invariant used by + // getCeoForCompany: reports_to IS NULL. + const agent = makeAgent({ + modelStrategy: "auto", + roleLevel: "ic", + reportsTo: null, + role: "CEO", + connectorId: "anthropic-api" as ConnectorId, + model: "claude-opus-4-7", + }); + const deps = makeDeps({ + enabledModels: [ + { connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" }, + { connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" }, + ], + capabilities: [claudeOpus, llama], + }); + const assignment = pickModelForAgent(agent, deps); + expect(assignment.modelId).toBe("claude-opus-4-7"); + }); +}); + +describe("inferTaskProfile — i18n", () => { + it("classifies Chinese role strings via substring match", async () => { + const { inferTaskProfile } = await import("../../src/main/agent-role-helpers"); + expect(inferTaskProfile("高级架构师", null, "director").domain).toBe("reasoning"); + expect(inferTaskProfile("前端工程师", null, "ic").domain).toBe("coding"); + expect(inferTaskProfile("内容运营", null, "ic").domain).toBe("writing"); + expect(inferTaskProfile("产品经理", null, "manager").domain).toBe("writing"); + }); + + it("recognises Chinese trivial-tier cues", async () => { + const { inferTaskProfile } = await import("../../src/main/agent-role-helpers"); + expect(inferTaskProfile("实习生", "数据标注实习生", "ic").requiredTier).toBe("trivial"); + expect(inferTaskProfile("初级工程师", null, "ic").requiredTier).toBe("trivial"); + expect(inferTaskProfile("助理", null, "ic").requiredTier).toBe("trivial"); + }); + + it("English classification still works after unicode tokenizer swap", async () => { + const { inferTaskProfile } = await import("../../src/main/agent-role-helpers"); + expect(inferTaskProfile("Senior Architect", null, "director").domain).toBe("reasoning"); + expect(inferTaskProfile("Staff Engineer", null, "lead").domain).toBe("coding"); + expect(inferTaskProfile("Marketing Lead", null, "lead").domain).toBe("writing"); + // 'lead' keyword must not match 'leaderboard' / 'cleaner' substrings — + // token-based matching is still in effect for Latin keywords. + expect(inferTaskProfile("Cleaner", null, "ic").domain).toBe("writing"); + }); +}); + +describe("pickFromCandidates — pure rubric", () => { + const claudeOpus: ModelCapability = { + provider: "anthropic", modelId: "claude-opus-4-7", + reasoning: 5, coding: 5, writing: 5, + tier: "hard", costPerMtokOut: 75, updatedAt: "", + }; + const claudeSonnet: ModelCapability = { + provider: "anthropic", modelId: "claude-sonnet-4-6", + reasoning: 4, coding: 5, writing: 5, + tier: "standard", costPerMtokOut: 15, updatedAt: "", + }; + const kimiK2: ModelCapability = { + provider: "fireworks", modelId: "kimi-k2", + reasoning: 4, coding: 4, writing: 5, + tier: "standard", costPerMtokOut: 3, updatedAt: "", + }; + const llama: ModelCapability = { + provider: "fireworks", modelId: "llama-70b", + reasoning: 2, coding: 2, writing: 2, + tier: "trivial", costPerMtokOut: 1, updatedAt: "", + }; + + const allEnabled: EnabledModel[] = [ + { provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" }, + { provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-sonnet-4-6" }, + { provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "kimi-k2" }, + { provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }, + ]; + + it("picks the cheapest qualifying model (standard/writing → kimi beats sonnet on price)", () => { + const winner = pickFromCandidates( + { domain: "writing", requiredTier: "standard" }, + allEnabled, + [claudeOpus, claudeSonnet, kimiK2, llama], + ); + expect(winner?.modelId).toBe("kimi-k2"); + }); + + it("hard tier requires tier≥hard even if domain score clears the floor", () => { + // writing=5 qualifies sonnet and kimi numerically, but both are tier=standard. + // Only opus (tier=hard) survives. + const winner = pickFromCandidates( + { domain: "writing", requiredTier: "hard" }, + allEnabled, + [claudeOpus, claudeSonnet, kimiK2, llama], + ); + expect(winner?.modelId).toBe("claude-opus-4-7"); + }); + + it("returns null when no candidate clears the bar", () => { + // Ask for hard/reasoning ≥4 but only provide trivial-tier llama. + const winner = pickFromCandidates( + { domain: "reasoning", requiredTier: "hard" }, + [{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }], + [llama], + ); + expect(winner).toBeNull(); + }); + + it("returns null on an empty candidate list", () => { + expect(pickFromCandidates({ domain: "coding", requiredTier: "trivial" }, [], [llama])).toBeNull(); + }); + + it("trivial tier still honours the domain floor (2)", () => { + // llama scores 2 on writing — exactly on the floor, so it qualifies. + const winner = pickFromCandidates( + { domain: "writing", requiredTier: "trivial" }, + [{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }], + [llama], + ); + expect(winner?.modelId).toBe("llama-70b"); + }); + + it("ignores enabled models with no capability row", () => { + // Only claudeOpus has a capability row; mystery model is filtered out + // despite appearing in the enabled list. + const winner = pickFromCandidates( + { domain: "reasoning", requiredTier: "hard" }, + [ + { provider: "mystery", connectorId: "mystery" as ConnectorId, modelId: "unknown" }, + { provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" }, + ], + [claudeOpus], + ); + expect(winner?.modelId).toBe("claude-opus-4-7"); + }); +}); + +describe("pickModelForAgent — internal rubric", () => { + it("exposes DOMAIN_SCORE_FLOOR and TIER_RANK for downstream tests", async () => { + const { __INTERNAL__ } = await import("../../src/main/model-dispatch"); + expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.trivial).toBe(2); + expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.standard).toBe(3); + expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.hard).toBe(4); + expect(__INTERNAL__.TIER_RANK.trivial).toBe(1); + expect(__INTERNAL__.TIER_RANK.standard).toBe(2); + expect(__INTERNAL__.TIER_RANK.hard).toBe(3); + }); +}); diff --git a/tests/unit/resolve-workspace.test.ts b/tests/unit/resolve-workspace.test.ts new file mode 100644 index 00000000..f2ec16dd --- /dev/null +++ b/tests/unit/resolve-workspace.test.ts @@ -0,0 +1,171 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: (value: string) => Buffer.from(value, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8"), + }, +})); + +import { AppDatabase } from "../../src/main/database"; +import { resolveWorkspaceWithLockMode } from "../../src/main/orchestration/resolve-workspace"; + +/** + * Regression tests for the workspace lock policy introduced in v0.4.168+: + * only `task.workspaceId` grants exclusive access. Agent-, project-, and + * company-scoped resolutions are all `shared` so teammates can run in + * parallel instead of queuing on a single workspace lock. + * + * See `resolve-workspace.ts` for the full rationale. + */ + +function seedCompany(db: AppDatabase) { + const companyId = db.saveCompany({ + name: "Parallel Co", + description: "Regression fixture", + status: "active", + }); + db.saveConnector({ + id: "openai-codex", + command: "codex", + model: "gpt-5", + envBindingText: "", + notes: "fixture", + }); + const workspaceId = db.saveWorkspace({ + companyId, + name: "Default", + localPath: "/tmp/fixture", + repoUrl: "https://github.com/example/fixture", + repoRef: "main", + isPrimary: true, + }); + return { companyId, workspaceId }; +} + +describe("resolveWorkspaceWithLockMode", () => { + let tempDir: string; + let db: AppDatabase; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "resolve-workspace-")); + db = new AppDatabase(tempDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("task-bound workspace → exclusive (explicit opt-in)", () => { + const { companyId, workspaceId } = seedCompany(db); + const result = resolveWorkspaceWithLockMode( + db, + { workspaceId, projectId: null }, + { workspaceId: null }, + companyId, + ); + expect(result.source).toBe("task"); + expect(result.lockMode).toBe("exclusive"); + expect(result.workspace?.id).toBe(workspaceId); + }); + + it("agent-bound workspace → shared (normal case: teammates share a repo)", () => { + const { companyId, workspaceId } = seedCompany(db); + const result = resolveWorkspaceWithLockMode( + db, + null, + { workspaceId }, + companyId, + ); + expect(result.source).toBe("agent"); + expect(result.lockMode).toBe("shared"); + }); + + it("project-bound workspace → shared (team collaboration space)", () => { + const { companyId } = seedCompany(db); + const projectId = db.saveProject({ + companyId, + name: "Platform", + description: "", + status: "active", + }); + // Create a second workspace bound to the project so the resolver's + // project-primary branch actually fires (the default seeded workspace + // has no project_id set). + db.saveWorkspace({ + companyId, + name: "Project repo", + localPath: "/tmp/project-ws", + repoUrl: "https://github.com/example/project", + repoRef: "main", + projectId, + isPrimary: true, + }); + + const result = resolveWorkspaceWithLockMode( + db, + { workspaceId: null, projectId }, + { workspaceId: null }, + companyId, + ); + expect(result.source).toBe("project"); + expect(result.lockMode).toBe("shared"); + }); + + it("company-fallback → shared (unchanged legacy behavior)", () => { + const { companyId } = seedCompany(db); + const result = resolveWorkspaceWithLockMode( + db, + null, + { workspaceId: null }, + companyId, + ); + expect(result.source).toBe("company_fallback"); + expect(result.lockMode).toBe("shared"); + }); + + it("task workspace wins over agent workspace even when both are set", () => { + const { companyId, workspaceId: agentWsId } = seedCompany(db); + const taskWsId = db.saveWorkspace({ + companyId, + name: "Task-only", + localPath: "/tmp/task-ws", + repoUrl: "https://github.com/example/task", + repoRef: "main", + isPrimary: false, + }); + const result = resolveWorkspaceWithLockMode( + db, + { workspaceId: taskWsId, projectId: null }, + { workspaceId: agentWsId }, + companyId, + ); + expect(result.source).toBe("task"); + expect(result.lockMode).toBe("exclusive"); + expect(result.workspace?.id).toBe(taskWsId); + }); + + it("no workspace at all → none", () => { + const companyId = db.saveCompany({ + name: "Empty Co", + description: "", + status: "active", + }); + const result = resolveWorkspaceWithLockMode( + db, + null, + { workspaceId: null }, + companyId, + ); + expect(result.source).toBe("none"); + expect(result.lockMode).toBe("none"); + expect(result.workspace).toBeNull(); + }); +}); diff --git a/tests/unit/session-management.test.ts b/tests/unit/session-management.test.ts index 7bda47d3..30b533a8 100644 --- a/tests/unit/session-management.test.ts +++ b/tests/unit/session-management.test.ts @@ -577,12 +577,16 @@ describe("session management service", () => { const session = db.getActiveTaskSession(taskId)!; const svc = createSessionManagementService({ db, logger: mockLogger }); - const handoff = svc.generateHandoff(taskId, session); + // Handoff is now agent-scoped: it walks the agent's recent runs + // (across tasks + heartbeats) rather than a single task. The task + // title is no longer in the handoff header because the session + // does not belong to any one task anymore. + const handoff = svc.generateHandoff(session); expect(handoff).toContain("# Session Handoff"); - expect(handoff).toContain("Implement session rotation"); // task title expect(handoff).toContain("## Recent Run History"); expect(handoff).toContain("succeeded"); + expect(handoff).toContain("Implemented initial logic"); // run summary expect(handoff).toContain("15000 total tokens"); // 10000 + 5000 }); });