@loicngr/kobo 1.6.8 → 1.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/README.md +4 -1
- package/dist/mcp-server/kobo-tasks-handlers.js +19 -1
- package/dist/mcp-server/kobo-tasks-server.js +27 -1
- package/dist/server/db/migrations.js +22 -0
- package/dist/server/db/schema.js +4 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/images.js +59 -0
- package/dist/server/routes/workspaces.js +211 -21
- package/dist/server/services/agent/engines/claude-code/args-builder.js +6 -0
- package/dist/server/services/agent/engines/claude-code/engine.js +6 -0
- package/dist/server/services/agent/engines/claude-code/stream-parser.js +162 -0
- package/dist/server/services/agent/orchestrator.js +171 -18
- package/dist/server/services/auto-loop-service.js +311 -0
- package/dist/server/services/workspace-service.js +47 -0
- package/dist/server/utils/git-ops.js +13 -4
- package/dist/shared/auto-loop-prompts.js +28 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-ZLFD0ABF.css → ActivityFeed-DtM6pJvz.css} +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-jxfDBgtk.js +7 -0
- package/src/client/dist/spa/assets/{ClosePopup-DTgXzcoa.js → ClosePopup-DkLittac.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-Bk5v8_20.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-uBHjVyx5.js +2 -0
- package/src/client/dist/spa/assets/DiffViewer-B5spOKjh.js +2 -0
- package/src/client/dist/spa/assets/{HealthPage-BNv_dnMz.js → HealthPage-DnUDXD7f.js} +1 -1
- package/src/client/dist/spa/assets/MainLayout-CDR4Le5c.css +1 -0
- package/src/client/dist/spa/assets/{MainLayout-NzuypipH.js → MainLayout-Cu2p6Yzp.js} +17 -17
- package/src/client/dist/spa/assets/QChip-bl3YRhax.js +1 -0
- package/src/client/dist/spa/assets/{QExpansionItem-HLBjHx-0.js → QExpansionItem-CWw6ZujM.js} +1 -1
- package/src/client/dist/spa/assets/{QItemSection-BzWLL-V-.js → QItemSection-CiY_LK5Y.js} +1 -1
- package/src/client/dist/spa/assets/{QScrollArea-CBW6shMb.js → QScrollArea-DpCqRRE0.js} +1 -1
- package/src/client/dist/spa/assets/QTabPanels-C4bZGqml.js +1 -0
- package/src/client/dist/spa/assets/{QTooltip-DbEBexRN.js → QTooltip-BIDjo2hJ.js} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-B3m_OWli.js → SearchPage-BL03e4yO.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-DODqugln.js +1 -0
- package/src/client/dist/spa/assets/{TouchPan-Y_Bxzun2.js → TouchPan-vsl78kxF.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-CM676R3B.css → WorkspacePage-CI1BxN04.css} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-CvR1wkIu.js +4 -0
- package/src/client/dist/spa/assets/{build-path-tree-DOPXkGhj.js → build-path-tree-BOfvTwdg.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-BPObkLMQ.js → cssMode-CoOgcS9Q.js} +1 -1
- package/src/client/dist/spa/assets/{documents-DMvdjtPf.js → documents-Capxg1Is.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-BpCtstKS.js → editor.api-BXQZAhGS.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-C2h6FfOt.js → editor.main-DFavPtYi.js} +3 -3
- package/src/client/dist/spa/assets/{formatters-D7eTm7uK.js → formatters-CX2gvLFv.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-DUmHGv4C.js → freemarker2-CxnHsTrj.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BU6pjzPg.js → handlebars-MdkEOy37.js} +1 -1
- package/src/client/dist/spa/assets/{html-A5-15bWl.js → html-BWqDGW4J.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C3KkomG3.js → htmlMode-CO3tFPX5.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BshFP-3_.js +1 -0
- package/src/client/dist/spa/assets/index-ljurK0Xv.js +2 -0
- package/src/client/dist/spa/assets/is-DUKatk8N.js +1 -0
- package/src/client/dist/spa/assets/{javascript-ggaOKiy5.js → javascript-I8UtlP5w.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Bk-QMPGJ.js → jsonMode-Z4_dv7Ex.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-CJdzn-JB.js → liquid-MmYIYsxN.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-D5wRO-st.js → mdx-05Yi5ibq.js} +1 -1
- package/src/client/dist/spa/assets/models-BWwzb9Qz.js +1 -0
- package/src/client/dist/spa/assets/{monaco.contribution-CPqJifAu.js → monaco.contribution-BcmbPJhi.js} +2 -2
- package/src/client/dist/spa/assets/{python-DHI9rQDm.js → python-DApFIC6r.js} +1 -1
- package/src/client/dist/spa/assets/rate-limit-labels-BeAbIcPH.js +10 -0
- package/src/client/dist/spa/assets/{razor-CzQWNzhW.js → razor-IqeohLNL.js} +1 -1
- package/src/client/dist/spa/assets/{scroll-C-Vz5BD9.js → scroll-CYWyxBdv.js} +1 -1
- package/src/client/dist/spa/assets/settings-CAILUJXO.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-DPkpdkNr.js → tsMode-B6nLj3Ks.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-Dgm0x_-O.js → typescript-DHsUK_D5.js} +1 -1
- package/src/client/dist/spa/assets/{use-checkbox-BduGd8xg.js → use-checkbox-B_o-iLG2.js} +1 -1
- package/src/client/dist/spa/assets/{xml-BeXyffrj.js → xml-B_o_LoiA.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-D4UE_1wU.js → yaml-mPCNKMRE.js} +1 -1
- package/src/client/dist/spa/index.html +9 -8
- package/src/mcp-server/kobo-tasks-handlers.ts +24 -1
- package/src/mcp-server/kobo-tasks-server.ts +29 -0
- package/src/client/dist/spa/assets/ActivityFeed-Bn9tpyLw.js +0 -7
- package/src/client/dist/spa/assets/CreatePage-CYtKx6Ji.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-DDPmb3I-.js +0 -2
- package/src/client/dist/spa/assets/DiffViewer-CM3g7W7U.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-BeKCjOA2.css +0 -1
- package/src/client/dist/spa/assets/QChip-1nQ_KMFF.js +0 -1
- package/src/client/dist/spa/assets/QDialog-G448EJG4.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-Cw4nnIbR.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CpQm15XA.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-BQzk5qfr.js +0 -4
- package/src/client/dist/spa/assets/i18n-CIduhxS0.js +0 -1
- package/src/client/dist/spa/assets/index-QcUb2Iwh.js +0 -2
- package/src/client/dist/spa/assets/models-CwWSex3X.js +0 -1
- package/src/client/dist/spa/assets/rate-limit-labels-Su-L56A2.js +0 -6
- /package/src/client/dist/spa/assets/{QBadge-Di02fu2H.js → QBadge-DqtcDv8D.js} +0 -0
- /package/src/client/dist/spa/assets/{QBtn-CyzfM9-_.js → QBtn-DHwAb18J.js} +0 -0
- /package/src/client/dist/spa/assets/{QItemLabel-Czw5g0px.js → QItemLabel-Codqjisk.js} +0 -0
- /package/src/client/dist/spa/assets/{QList-D2GuTeLl.js → QList-Bl9824vi.js} +0 -0
- /package/src/client/dist/spa/assets/{QPage-BTzNQlb1.js → QPage-Dn4E3GHB.js} +0 -0
- /package/src/client/dist/spa/assets/{QSlideTransition-s6ZkYsLs.js → QSlideTransition-BQxI8l5r.js} +0 -0
- /package/src/client/dist/spa/assets/{QSpace-0zdF1m5x.js → QSpace-BNr0AftG.js} +0 -0
- /package/src/client/dist/spa/assets/{QSpinnerDots-By20ptst.js → QSpinnerDots-DEiRooBD.js} +0 -0
- /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cj6tcsj6.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
- /package/src/client/dist/spa/assets/{abap-DiwvWnMr.js → abap-Bgec7Keq.js} +0 -0
- /package/src/client/dist/spa/assets/{apex-CmtZjKlf.js → apex-VBlPwEoQ.js} +0 -0
- /package/src/client/dist/spa/assets/{azcli-DL2My_i-.js → azcli-DKqrEFBx.js} +0 -0
- /package/src/client/dist/spa/assets/{bat-B-nC98wG.js → bat-DdgQWy_0.js} +0 -0
- /package/src/client/dist/spa/assets/{bicep-Ju5MwOgh.js → bicep-CRMM43EB.js} +0 -0
- /package/src/client/dist/spa/assets/{cameligo-8Eu1TyBr.js → cameligo-UatALtML.js} +0 -0
- /package/src/client/dist/spa/assets/{clojure-u-RpMkH3.js → clojure-D8JU08RA.js} +0 -0
- /package/src/client/dist/spa/assets/{coffee-CdA7bbTe.js → coffee-C56wu358.js} +0 -0
- /package/src/client/dist/spa/assets/{cpp-CzNFP8ks.js → cpp-CyZLvhJG.js} +0 -0
- /package/src/client/dist/spa/assets/{csharp-j1LThmcE.js → csharp-BJl3ixva.js} +0 -0
- /package/src/client/dist/spa/assets/{csp-CLRC61y6.js → csp-CxEKxmO-.js} +0 -0
- /package/src/client/dist/spa/assets/{css-r6rC_7P2.js → css-B0t_muXd.js} +0 -0
- /package/src/client/dist/spa/assets/{cypher-CW08XVUh.js → cypher-D1hqiMFD.js} +0 -0
- /package/src/client/dist/spa/assets/{dart-Cs9aL5T_.js → dart-Bz550Pyv.js} +0 -0
- /package/src/client/dist/spa/assets/{dockerfile-BWM0M184.js → dockerfile-CIXgVAuA.js} +0 -0
- /package/src/client/dist/spa/assets/{ecl-MJJuer5P.js → ecl-D9qbvZoA.js} +0 -0
- /package/src/client/dist/spa/assets/{elixir-D2AIuXqn.js → elixir-b2M38fAy.js} +0 -0
- /package/src/client/dist/spa/assets/{flow9-B2H24giC.js → flow9-Dq1UYMkt.js} +0 -0
- /package/src/client/dist/spa/assets/{fsharp-CMk2OIJN.js → fsharp-CFNadkg7.js} +0 -0
- /package/src/client/dist/spa/assets/{go-BrMkuJg0.js → go-dSur1iB2.js} +0 -0
- /package/src/client/dist/spa/assets/{graphql-PSR1UKGv.js → graphql-qyhAo11d.js} +0 -0
- /package/src/client/dist/spa/assets/{hcl-DAQrbDOW.js → hcl-DFzjMyzm.js} +0 -0
- /package/src/client/dist/spa/assets/{ini-0TG5BxW0.js → ini-TdzA8TIl.js} +0 -0
- /package/src/client/dist/spa/assets/{java-rgorz17v.js → java-CSGA9pkE.js} +0 -0
- /package/src/client/dist/spa/assets/{julia-C8VMdHm8.js → julia-9izz5OsY.js} +0 -0
- /package/src/client/dist/spa/assets/{kotlin-CllWo3gX.js → kotlin-DuPK7AtF.js} +0 -0
- /package/src/client/dist/spa/assets/{less-Cgca25AP.js → less-B8d93iCg.js} +0 -0
- /package/src/client/dist/spa/assets/{lexon-D0GHdBaw.js → lexon-DWtEIyu7.js} +0 -0
- /package/src/client/dist/spa/assets/{lua-DmRsNG-P.js → lua-Ciq0OGgt.js} +0 -0
- /package/src/client/dist/spa/assets/{m3-BgL5dNKT.js → m3-Cki6JWj_.js} +0 -0
- /package/src/client/dist/spa/assets/{markdown-BuJfycGS.js → markdown-Cu47xwU0.js} +0 -0
- /package/src/client/dist/spa/assets/{mips-C9m_93PR.js → mips-BM8ui995.js} +0 -0
- /package/src/client/dist/spa/assets/{msdax-CpFHC9OI.js → msdax-DqLio0_c.js} +0 -0
- /package/src/client/dist/spa/assets/{mysql-qFvltsqN.js → mysql-v1wbjJOq.js} +0 -0
- /package/src/client/dist/spa/assets/{objective-c-Bnmr858J.js → objective-c-CQl3PGSB.js} +0 -0
- /package/src/client/dist/spa/assets/{pascal-WP0_D5AO.js → pascal-D4iW0ZtD.js} +0 -0
- /package/src/client/dist/spa/assets/{pascaligo-Blom4Rij.js → pascaligo-BdC9CZdj.js} +0 -0
- /package/src/client/dist/spa/assets/{perl-B-vk8g64.js → perl-BL10m4XD.js} +0 -0
- /package/src/client/dist/spa/assets/{pgsql-Cgvz6v67.js → pgsql-Be_oqVo3.js} +0 -0
- /package/src/client/dist/spa/assets/{php-8a3Lrw9m.js → php-BtvXSFRI.js} +0 -0
- /package/src/client/dist/spa/assets/{pla-DuFqEZ8V.js → pla-B2vUy15C.js} +0 -0
- /package/src/client/dist/spa/assets/{postiats-DkLtSgkp.js → postiats-CbmTTfXr.js} +0 -0
- /package/src/client/dist/spa/assets/{powerquery-BJ1aNepW.js → powerquery-DszLhJGx.js} +0 -0
- /package/src/client/dist/spa/assets/{powershell-rE98k687.js → powershell-B0dYktF6.js} +0 -0
- /package/src/client/dist/spa/assets/{private.use-form-C5G_3nU5.js → private.use-form-Dlb0iQZh.js} +0 -0
- /package/src/client/dist/spa/assets/{protobuf-CUheFacr.js → protobuf-CZvaj1VX.js} +0 -0
- /package/src/client/dist/spa/assets/{pug-LDcAMD8w.js → pug-CPDx1B3S.js} +0 -0
- /package/src/client/dist/spa/assets/{qsharp-DUKSQoR1.js → qsharp-CDP9TFLl.js} +0 -0
- /package/src/client/dist/spa/assets/{r-D-QApv87.js → r-8DbbFX2l.js} +0 -0
- /package/src/client/dist/spa/assets/{redis-SXdDyWR9.js → redis-DRWj9MtJ.js} +0 -0
- /package/src/client/dist/spa/assets/{redshift-Y6lsCryn.js → redshift-C6cElE_5.js} +0 -0
- /package/src/client/dist/spa/assets/{restructuredtext-edObr9a8.js → restructuredtext-W9pS9n3m.js} +0 -0
- /package/src/client/dist/spa/assets/{ruby-CNnUfF-8.js → ruby-BKnzWnk-.js} +0 -0
- /package/src/client/dist/spa/assets/{rust-IHUZWzBr.js → rust-YPCclWwe.js} +0 -0
- /package/src/client/dist/spa/assets/{sb-DrUvY44N.js → sb-BgM4DTFb.js} +0 -0
- /package/src/client/dist/spa/assets/{scala-B4hbXGLM.js → scala-fz1OPLMl.js} +0 -0
- /package/src/client/dist/spa/assets/{scheme-BGrd12j3.js → scheme-8Uz1RIbu.js} +0 -0
- /package/src/client/dist/spa/assets/{scss-x5G1ES4U.js → scss-Djo3IYXr.js} +0 -0
- /package/src/client/dist/spa/assets/{shell-DOehe2Y8.js → shell-CINF5Tx_.js} +0 -0
- /package/src/client/dist/spa/assets/{solidity-BeRvcwWV.js → solidity-GgiNEuUm.js} +0 -0
- /package/src/client/dist/spa/assets/{sophia-DZbkUNjy.js → sophia-Culj97P9.js} +0 -0
- /package/src/client/dist/spa/assets/{sparql-B7_oi5-h.js → sparql-C2ZlpxOY.js} +0 -0
- /package/src/client/dist/spa/assets/{sql-CTlsFWVE.js → sql-BEf5Pg7Y.js} +0 -0
- /package/src/client/dist/spa/assets/{st-DJVEJdPE.js → st-CT6UUoeH.js} +0 -0
- /package/src/client/dist/spa/assets/{swift-CwhT3fYa.js → swift-B5g0xTG3.js} +0 -0
- /package/src/client/dist/spa/assets/{systemverilog-BQN63pkN.js → systemverilog-CEgQz9DR.js} +0 -0
- /package/src/client/dist/spa/assets/{tcl-DqwfpskA.js → tcl-D0qL2L0I.js} +0 -0
- /package/src/client/dist/spa/assets/{touch-B2uuAH_y.js → touch-Bj_Fr4kC.js} +0 -0
- /package/src/client/dist/spa/assets/{twig-BiyenUgc.js → twig-BFUAVf1E.js} +0 -0
- /package/src/client/dist/spa/assets/{typespec-CWOJribt.js → typespec-CjVVcNKm.js} +0 -0
- /package/src/client/dist/spa/assets/{use-id-BmXMngYX.js → use-id-C93QQwrt.js} +0 -0
- /package/src/client/dist/spa/assets/{use-quasar-BBrzedjR.js → use-quasar-Cc4smfg5.js} +0 -0
- /package/src/client/dist/spa/assets/{vb-Cq5F87m3.js → vb-CZJr-DQz.js} +0 -0
- /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-BJlZEYnA.js} +0 -0
- /package/src/client/dist/spa/assets/{wgsl-BAvW2lVr.js → wgsl-ivoXUo2e.js} +0 -0
|
@@ -21,6 +21,7 @@ export function createClaudeCodeEngine() {
|
|
|
21
21
|
effort: options.effort,
|
|
22
22
|
permissionMode: options.permissionMode ?? 'auto-accept',
|
|
23
23
|
skipPermissions: options.settings.dangerouslySkipPermissions ?? true,
|
|
24
|
+
permissionProfile: options.permissionProfile,
|
|
24
25
|
resumeFromEngineSessionId: options.resumeFromEngineSessionId,
|
|
25
26
|
mcpConfigPath,
|
|
26
27
|
});
|
|
@@ -70,9 +71,14 @@ export function createClaudeCodeEngine() {
|
|
|
70
71
|
lower.includes('rate_limit_exceeded') ||
|
|
71
72
|
(lower.includes('429') && lower.includes('rate')) ||
|
|
72
73
|
lower.includes('quota exceeded');
|
|
74
|
+
const isResumeFailed = lower.includes('no conversation found with session id');
|
|
73
75
|
if (isQuota) {
|
|
74
76
|
onEvent({ kind: 'error', category: 'quota', message: line });
|
|
75
77
|
}
|
|
78
|
+
else if (isResumeFailed) {
|
|
79
|
+
onEvent({ kind: 'error', category: 'resume_failed', message: line });
|
|
80
|
+
console.warn(`[claude-engine stderr] ${line}`);
|
|
81
|
+
}
|
|
76
82
|
else if (line.trim().length > 0 && !isBenignStderr(line)) {
|
|
77
83
|
console.warn(`[claude-engine stderr] ${line}`);
|
|
78
84
|
}
|
|
@@ -26,6 +26,161 @@ function makeBucket(id, source) {
|
|
|
26
26
|
const details = used !== undefined && limit !== undefined ? `${String(used)} / ${String(limit)}` : undefined;
|
|
27
27
|
return { id, label, usedPct: Math.max(0, Math.min(100, usedPct)), resetsAt, details };
|
|
28
28
|
}
|
|
29
|
+
// ── Text-based quota detection ────────────────────────────────────────────────
|
|
30
|
+
// When the `claude -p` CLI hits a rate limit mid-turn, it often does NOT emit
|
|
31
|
+
// a structured `rate_limit_event` nor an error on stderr — it just writes a
|
|
32
|
+
// plain-text message like:
|
|
33
|
+
// "You've hit your limit · resets 1:20pm (Europe/Paris)"
|
|
34
|
+
// and exits cleanly. Without the pattern below we'd see `session:ended` with
|
|
35
|
+
// `reason: 'completed'` and the auto-loop would spawn the next iteration
|
|
36
|
+
// immediately, only to hit the same wall again, N times, until stall kicks in.
|
|
37
|
+
//
|
|
38
|
+
// The regex captures the reset time (e.g. "1:20pm") and the timezone in
|
|
39
|
+
// parentheses (e.g. "Europe/Paris") so we can convert to an ISO 8601
|
|
40
|
+
// timestamp and feed handleQuota the same way a structured event would.
|
|
41
|
+
const QUOTA_TEXT_PATTERN = /you(?:'ve|\s+have)\s+hit\s+your\s+limit[^.\n]*?resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
|
|
42
|
+
function extractZonedDateTimeParts(date, timeZone) {
|
|
43
|
+
try {
|
|
44
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
45
|
+
timeZone,
|
|
46
|
+
year: 'numeric',
|
|
47
|
+
month: '2-digit',
|
|
48
|
+
day: '2-digit',
|
|
49
|
+
hour: '2-digit',
|
|
50
|
+
minute: '2-digit',
|
|
51
|
+
second: '2-digit',
|
|
52
|
+
hour12: false,
|
|
53
|
+
});
|
|
54
|
+
const parts = formatter.formatToParts(date);
|
|
55
|
+
const read = (type) => {
|
|
56
|
+
const value = parts.find((part) => part.type === type)?.value;
|
|
57
|
+
if (!value)
|
|
58
|
+
return null;
|
|
59
|
+
const parsed = Number.parseInt(value, 10);
|
|
60
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
61
|
+
};
|
|
62
|
+
const year = read('year');
|
|
63
|
+
const month = read('month');
|
|
64
|
+
const day = read('day');
|
|
65
|
+
const hour = read('hour');
|
|
66
|
+
const minute = read('minute');
|
|
67
|
+
const second = read('second');
|
|
68
|
+
if ([year, month, day, hour, minute, second].some((value) => value === null))
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
year: year,
|
|
72
|
+
month: month,
|
|
73
|
+
day: day,
|
|
74
|
+
hour: hour,
|
|
75
|
+
minute: minute,
|
|
76
|
+
second: second,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function dateTimePartsToUtcMs(parts) {
|
|
84
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
|
|
85
|
+
}
|
|
86
|
+
function zonedDateTimeToUtc(parts, timeZone) {
|
|
87
|
+
let guess = new Date(dateTimePartsToUtcMs(parts));
|
|
88
|
+
for (let i = 0; i < 4; i += 1) {
|
|
89
|
+
const observed = extractZonedDateTimeParts(guess, timeZone);
|
|
90
|
+
if (!observed)
|
|
91
|
+
return undefined;
|
|
92
|
+
const diffMs = dateTimePartsToUtcMs(parts) - dateTimePartsToUtcMs(observed);
|
|
93
|
+
if (diffMs === 0)
|
|
94
|
+
return guess.toISOString();
|
|
95
|
+
guess = new Date(guess.getTime() + diffMs);
|
|
96
|
+
}
|
|
97
|
+
const observed = extractZonedDateTimeParts(guess, timeZone);
|
|
98
|
+
if (observed &&
|
|
99
|
+
observed.year === parts.year &&
|
|
100
|
+
observed.month === parts.month &&
|
|
101
|
+
observed.day === parts.day &&
|
|
102
|
+
observed.hour === parts.hour &&
|
|
103
|
+
observed.minute === parts.minute) {
|
|
104
|
+
return guess.toISOString();
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse the time string Claude outputs (e.g. "1:20pm" + "Europe/Paris")
|
|
110
|
+
* into an ISO 8601 UTC timestamp. Returns `undefined` if anything looks
|
|
111
|
+
* off so handleQuota falls back to its ladder.
|
|
112
|
+
*/
|
|
113
|
+
function parseQuotaResetsAt(hourStr, minuteStr, ampm, tz, now = new Date()) {
|
|
114
|
+
const hour24 = (() => {
|
|
115
|
+
const h = Number.parseInt(hourStr, 10);
|
|
116
|
+
if (Number.isNaN(h) || h < 0 || h > 23)
|
|
117
|
+
return null;
|
|
118
|
+
if (!ampm)
|
|
119
|
+
return h;
|
|
120
|
+
const pm = ampm.toLowerCase() === 'pm';
|
|
121
|
+
if (h === 12)
|
|
122
|
+
return pm ? 12 : 0;
|
|
123
|
+
return pm ? h + 12 : h;
|
|
124
|
+
})();
|
|
125
|
+
if (hour24 === null)
|
|
126
|
+
return undefined;
|
|
127
|
+
const minute = minuteStr ? Number.parseInt(minuteStr, 10) : 0;
|
|
128
|
+
if (Number.isNaN(minute) || minute < 0 || minute > 59)
|
|
129
|
+
return undefined;
|
|
130
|
+
if (tz) {
|
|
131
|
+
const zonedNow = extractZonedDateTimeParts(now, tz);
|
|
132
|
+
if (zonedNow) {
|
|
133
|
+
const targetDate = new Date(Date.UTC(zonedNow.year, zonedNow.month - 1, zonedNow.day));
|
|
134
|
+
const nowInZoneMs = dateTimePartsToUtcMs(zonedNow);
|
|
135
|
+
const targetTodayMs = Date.UTC(zonedNow.year, zonedNow.month - 1, zonedNow.day, hour24, minute, 0, 0);
|
|
136
|
+
if (targetTodayMs < nowInZoneMs - 60_000) {
|
|
137
|
+
targetDate.setUTCDate(targetDate.getUTCDate() + 1);
|
|
138
|
+
}
|
|
139
|
+
const zonedIso = zonedDateTimeToUtc({
|
|
140
|
+
year: targetDate.getUTCFullYear(),
|
|
141
|
+
month: targetDate.getUTCMonth() + 1,
|
|
142
|
+
day: targetDate.getUTCDate(),
|
|
143
|
+
hour: hour24,
|
|
144
|
+
minute,
|
|
145
|
+
second: 0,
|
|
146
|
+
}, tz);
|
|
147
|
+
if (zonedIso)
|
|
148
|
+
return zonedIso;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Fallback for missing/invalid timezone names: interpret the wall-clock time
|
|
152
|
+
// in the local machine timezone so quota handling still works on plain
|
|
153
|
+
// "resets 11am" messages.
|
|
154
|
+
const target = new Date(now);
|
|
155
|
+
target.setHours(hour24, minute, 0, 0);
|
|
156
|
+
if (target.getTime() < now.getTime() - 60_000) {
|
|
157
|
+
target.setDate(target.getDate() + 1);
|
|
158
|
+
}
|
|
159
|
+
return target.toISOString();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Scan a text block for a quota announcement. Returns synthetic
|
|
163
|
+
* `rate_limit` + `error { category: 'quota' }` events when the pattern
|
|
164
|
+
* matches, otherwise `null`. The caller pushes both events into the
|
|
165
|
+
* stream so handleQuota picks up the reset time and schedules the
|
|
166
|
+
* retry at the parsed moment.
|
|
167
|
+
*/
|
|
168
|
+
function extractQuotaFromText(text) {
|
|
169
|
+
const match = QUOTA_TEXT_PATTERN.exec(text);
|
|
170
|
+
if (!match)
|
|
171
|
+
return null;
|
|
172
|
+
const [, hourStr, minuteStr, ampm, tz] = match;
|
|
173
|
+
const resetsAt = parseQuotaResetsAt(hourStr, minuteStr, ampm, tz);
|
|
174
|
+
const bucket = {
|
|
175
|
+
id: 'text-detected',
|
|
176
|
+
usedPct: 100,
|
|
177
|
+
resetsAt,
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
error: { kind: 'error', category: 'quota', message: match[0] },
|
|
181
|
+
rateLimit: { kind: 'rate_limit', info: { buckets: [bucket] } },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
29
184
|
function normalizeRateLimitInfo(info) {
|
|
30
185
|
const buckets = [];
|
|
31
186
|
if (typeof info.rateLimitType === 'string') {
|
|
@@ -149,6 +304,13 @@ export function parseClaudeLine(line, state) {
|
|
|
149
304
|
if (blockType === 'text' && typeof block.text === 'string') {
|
|
150
305
|
events.push({ kind: 'message:text', messageId, text: block.text, streaming: true });
|
|
151
306
|
msgState.sawText = true;
|
|
307
|
+
// Synthesise quota events when the CLI reports the limit in prose.
|
|
308
|
+
// See extractQuotaFromText above for why this is needed in `-p` mode.
|
|
309
|
+
const synth = extractQuotaFromText(block.text);
|
|
310
|
+
if (synth) {
|
|
311
|
+
events.push(synth.rateLimit);
|
|
312
|
+
events.push(synth.error);
|
|
313
|
+
}
|
|
152
314
|
}
|
|
153
315
|
if (blockType === 'tool_use') {
|
|
154
316
|
events.push({
|
|
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
|
|
|
3
3
|
import { getDb } from '../../db/index.js';
|
|
4
4
|
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
|
|
5
5
|
import { unregisterProcess } from '../../utils/process-tracker.js';
|
|
6
|
+
import * as autoLoopService from '../auto-loop-service.js';
|
|
6
7
|
import { getEffectiveSettings } from '../settings-service.js';
|
|
7
8
|
import * as wakeupService from '../wakeup-service.js';
|
|
8
9
|
import { emitEphemeral } from '../websocket-service.js';
|
|
@@ -33,6 +34,8 @@ let availableSkills = (() => {
|
|
|
33
34
|
})();
|
|
34
35
|
/** workspaceId -> retry count (for quota backoff) */
|
|
35
36
|
const retryCounts = new Map();
|
|
37
|
+
/** Tracks workspaces where the current session failed due to a stale --resume session ID. */
|
|
38
|
+
const resumeFailedSet = new Set();
|
|
36
39
|
/** workspaceId -> backoff timer */
|
|
37
40
|
const backoffTimers = new Map();
|
|
38
41
|
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
|
@@ -251,8 +254,33 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId) {
|
|
|
251
254
|
return agentSessionId;
|
|
252
255
|
}
|
|
253
256
|
// ── Event handler ─────────────────────────────────────────────────────────────
|
|
257
|
+
/**
|
|
258
|
+
* Snapshot of the `tasks` done-count at `session:started`, read back and
|
|
259
|
+
* cleared at `session:ended` to compute the per-session delta. Used by
|
|
260
|
+
* `auto-loop-service.onSessionEnded` for stall detection.
|
|
261
|
+
*/
|
|
262
|
+
const tasksDoneSnapshot = new Map();
|
|
263
|
+
function getDoneTaskCount(workspaceId) {
|
|
264
|
+
const db = getDb();
|
|
265
|
+
const row = db
|
|
266
|
+
.prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status = ?')
|
|
267
|
+
.get(workspaceId, 'done');
|
|
268
|
+
return row.c;
|
|
269
|
+
}
|
|
270
|
+
/** Clear the in-memory done-count snapshot for a workspace (called on delete). */
|
|
271
|
+
export function forgetTasksDoneSnapshot(workspaceId) {
|
|
272
|
+
tasksDoneSnapshot.delete(workspaceId);
|
|
273
|
+
}
|
|
254
274
|
function handleEvent(workspaceId, agentSessionId, ev) {
|
|
255
275
|
routeEvent(workspaceId, agentSessionId, ev);
|
|
276
|
+
if (ev.kind === 'rate_limit') {
|
|
277
|
+
latestRateLimitInfo.set(workspaceId, ev.info);
|
|
278
|
+
}
|
|
279
|
+
// Snapshot the done-count at session start so the session:ended hook below
|
|
280
|
+
// can compute a delta for auto-loop stall detection.
|
|
281
|
+
if (ev.kind === 'session:started') {
|
|
282
|
+
tasksDoneSnapshot.set(workspaceId, getDoneTaskCount(workspaceId));
|
|
283
|
+
}
|
|
256
284
|
if (ev.kind === 'tool:call' && ev.name === 'ScheduleWakeup') {
|
|
257
285
|
const input = ev.input;
|
|
258
286
|
const delay = typeof input?.delaySeconds === 'number' ? input.delaySeconds : 0;
|
|
@@ -274,7 +302,10 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
274
302
|
}
|
|
275
303
|
if (ev.kind === 'session:brainstorm-complete') {
|
|
276
304
|
try {
|
|
277
|
-
|
|
305
|
+
const ws = getWs(workspaceId);
|
|
306
|
+
if (ws && ws.status !== 'executing') {
|
|
307
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
308
|
+
}
|
|
278
309
|
}
|
|
279
310
|
catch (err) {
|
|
280
311
|
console.error('[orchestrator] Failed to transition to executing:', err);
|
|
@@ -283,8 +314,31 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
283
314
|
if (ev.kind === 'error' && ev.category === 'quota') {
|
|
284
315
|
handleQuota(workspaceId, agentSessionId);
|
|
285
316
|
}
|
|
317
|
+
if (ev.kind === 'error' && ev.category === 'resume_failed') {
|
|
318
|
+
resumeFailedSet.add(workspaceId);
|
|
319
|
+
clearStaleEngineSessionId(workspaceId);
|
|
320
|
+
}
|
|
286
321
|
if (ev.kind === 'session:ended') {
|
|
287
|
-
onSessionEnded
|
|
322
|
+
// Pop the resume_failed flag before any cleanup so both onSessionEnded paths see it.
|
|
323
|
+
const isResumeFailed = resumeFailedSet.delete(workspaceId);
|
|
324
|
+
// Compute the auto-loop done-delta BEFORE the internal cleanup because
|
|
325
|
+
// onSessionEnded(internal) may throw / trigger follow-ups; also read the
|
|
326
|
+
// snapshot FIRST so a later re-entry can't overwrite it.
|
|
327
|
+
const before = tasksDoneSnapshot.get(workspaceId) ?? getDoneTaskCount(workspaceId);
|
|
328
|
+
const after = getDoneTaskCount(workspaceId);
|
|
329
|
+
const delta = Math.max(0, after - before);
|
|
330
|
+
tasksDoneSnapshot.delete(workspaceId);
|
|
331
|
+
// Internal cleanup REMOVES the controller from the map. This must run
|
|
332
|
+
// BEFORE autoLoopService.onSessionEnded → spawnNextIteration → startAgent,
|
|
333
|
+
// otherwise startAgent throws "Agent already running" because the
|
|
334
|
+
// just-ended controller is still in the map.
|
|
335
|
+
onSessionEnded(workspaceId, agentSessionId, ev.exitCode, isResumeFailed);
|
|
336
|
+
// When a resume failed the session exited with an error but there's
|
|
337
|
+
// nothing wrong with the workspace — the stale session ID has been cleared
|
|
338
|
+
// and the next iteration will start fresh. Treat it as 'completed' so
|
|
339
|
+
// auto-loop continues without disabling.
|
|
340
|
+
const effectiveReason = isResumeFailed ? 'completed' : ev.reason;
|
|
341
|
+
autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
|
|
288
342
|
}
|
|
289
343
|
if (ev.kind === 'session:started' && ev.engineSessionId) {
|
|
290
344
|
sessionIds.set(workspaceId, ev.engineSessionId);
|
|
@@ -312,7 +366,9 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
312
366
|
}
|
|
313
367
|
}
|
|
314
368
|
}
|
|
315
|
-
function onSessionEnded(workspaceId, agentSessionId, exitCode) {
|
|
369
|
+
function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = false) {
|
|
370
|
+
const currentWorkspace = getWs(workspaceId);
|
|
371
|
+
const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
|
|
316
372
|
const ctrl = controllers.get(workspaceId);
|
|
317
373
|
const wasStopping = ctrl?.status === 'stopping';
|
|
318
374
|
// Identity-preserving cleanup: only remove the controller if the map still
|
|
@@ -322,7 +378,9 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
|
|
|
322
378
|
controllers.delete(workspaceId);
|
|
323
379
|
}
|
|
324
380
|
unregisterProcess(workspaceId);
|
|
325
|
-
|
|
381
|
+
if (!preserveQuotaBackoff) {
|
|
382
|
+
retryCounts.delete(workspaceId);
|
|
383
|
+
}
|
|
326
384
|
// Update the agent_sessions row
|
|
327
385
|
try {
|
|
328
386
|
const db = getDb();
|
|
@@ -336,13 +394,27 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
|
|
|
336
394
|
// the "stopped" status. No legacy emit needed.
|
|
337
395
|
return;
|
|
338
396
|
}
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
backoffTimers.
|
|
397
|
+
// When the session hit quota, handleQuota() already transitioned the
|
|
398
|
+
// workspace to `quota` and armed the retry timer. Keep that timer alive
|
|
399
|
+
// and preserve the `quota` status so auto-loop can resume after reset.
|
|
400
|
+
if (!preserveQuotaBackoff) {
|
|
401
|
+
const pendingBackoff = backoffTimers.get(workspaceId);
|
|
402
|
+
if (pendingBackoff) {
|
|
403
|
+
clearTimeout(pendingBackoff);
|
|
404
|
+
backoffTimers.delete(workspaceId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (preserveQuotaBackoff) {
|
|
408
|
+
try {
|
|
409
|
+
markWorkspaceUnread(workspaceId);
|
|
410
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// best-effort
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
344
416
|
}
|
|
345
|
-
if (exitCode !== null && exitCode !== 0) {
|
|
417
|
+
if (exitCode !== null && exitCode !== 0 && !resumeFailed) {
|
|
346
418
|
try {
|
|
347
419
|
updateWorkspaceStatus(workspaceId, 'error');
|
|
348
420
|
}
|
|
@@ -408,6 +480,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
408
480
|
model,
|
|
409
481
|
effort: reasoningEffort,
|
|
410
482
|
permissionMode,
|
|
483
|
+
// Propagate the workspace's permission_profile to the engine (bypass vs
|
|
484
|
+
// strict). Defaults to 'bypass' — the pre-existing behavior — when the
|
|
485
|
+
// column is missing (fresh boot before migration landed) or unknown.
|
|
486
|
+
permissionProfile: ws?.permissionProfile === 'strict' ? 'strict' : 'bypass',
|
|
411
487
|
resumeFromEngineSessionId,
|
|
412
488
|
backendUrl: `http://127.0.0.1:${backendPort}`,
|
|
413
489
|
koboHome: (() => {
|
|
@@ -517,12 +593,81 @@ export function getRunningCount() {
|
|
|
517
593
|
return controllers.size;
|
|
518
594
|
}
|
|
519
595
|
/** Kobo built-in slash commands injected into the skill list (without leading /). */
|
|
520
|
-
const KOBO_COMMANDS = ['kobo-check-progress'];
|
|
596
|
+
const KOBO_COMMANDS = ['kobo-check-progress', 'kobo-prep-autoloop'];
|
|
521
597
|
/** Cached list of slash commands discovered from the last agent init, plus Kobo built-ins. */
|
|
522
598
|
export function getAvailableSkills() {
|
|
523
599
|
return [...KOBO_COMMANDS, ...availableSkills];
|
|
524
600
|
}
|
|
525
601
|
// ── Quota handling ────────────────────────────────────────────────────────────
|
|
602
|
+
/**
|
|
603
|
+
* Last `rate_limit.info` received per workspace. Used by handleQuota to
|
|
604
|
+
* schedule the backoff at the actual reset time instead of a hardcoded
|
|
605
|
+
* exponential. In-memory only — rebuilt on the next rate_limit event after
|
|
606
|
+
* a server restart.
|
|
607
|
+
*/
|
|
608
|
+
const latestRateLimitInfo = new Map();
|
|
609
|
+
/** Clear the rate-limit info cache for a workspace (called on deleteWorkspace). */
|
|
610
|
+
export function forgetRateLimitInfo(workspaceId) {
|
|
611
|
+
latestRateLimitInfo.delete(workspaceId);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Null out the engine_session_id on all agent_sessions rows for a workspace
|
|
615
|
+
* and clear the in-memory sessionIds cache. Called when a --resume attempt
|
|
616
|
+
* fails ("No conversation found with session ID") so that the next startAgent
|
|
617
|
+
* call starts a fresh conversation instead of retrying the stale ID.
|
|
618
|
+
*/
|
|
619
|
+
function clearStaleEngineSessionId(workspaceId) {
|
|
620
|
+
try {
|
|
621
|
+
const db = getDb();
|
|
622
|
+
db.prepare('UPDATE agent_sessions SET engine_session_id = NULL WHERE workspace_id = ?').run(workspaceId);
|
|
623
|
+
sessionIds.delete(workspaceId);
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
console.error('[orchestrator] Failed to clear stale engine session ID:', err);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const QUOTA_SAFETY_MARGIN_MS = 30_000;
|
|
630
|
+
const QUOTA_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
|
|
631
|
+
const QUOTA_SATURATION_THRESHOLD_PCT = 95;
|
|
632
|
+
/**
|
|
633
|
+
* Fallback backoff ladder (in minutes) used when the `rate_limit` info
|
|
634
|
+
* isn't usable. Indexed by `retryCount`; anything past the last entry
|
|
635
|
+
* clamps to the final value (5 h) — long enough to cross a weekly
|
|
636
|
+
* bucket reset if the rate_limit info truly never arrives.
|
|
637
|
+
*/
|
|
638
|
+
const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
|
|
639
|
+
/**
|
|
640
|
+
* Compute the delay before retrying a workspace hit by quota.
|
|
641
|
+
*
|
|
642
|
+
* Prefers the `resetsAt` of the saturated bucket with the furthest-future
|
|
643
|
+
* reset (a tighter bucket will unlock by then anyway). Falls back to a
|
|
644
|
+
* fixed ladder (15 → 30 → 60 → 180 → 300 min) whenever the rate_limit
|
|
645
|
+
* info is missing, malformed, or implausible.
|
|
646
|
+
*/
|
|
647
|
+
export function computeQuotaBackoffMs(workspaceId, retryCount) {
|
|
648
|
+
const info = latestRateLimitInfo.get(workspaceId);
|
|
649
|
+
if (info?.buckets?.length) {
|
|
650
|
+
const candidates = info.buckets
|
|
651
|
+
.filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && typeof b.resetsAt === 'string')
|
|
652
|
+
.map((b) => ({ resetsAt: b.resetsAt, ts: new Date(b.resetsAt).getTime() }))
|
|
653
|
+
.filter((x) => !Number.isNaN(x.ts))
|
|
654
|
+
.sort((a, b) => b.ts - a.ts);
|
|
655
|
+
const chosen = candidates[0];
|
|
656
|
+
if (chosen) {
|
|
657
|
+
const delta = chosen.ts - Date.now() + QUOTA_SAFETY_MARGIN_MS;
|
|
658
|
+
if (delta > 0 && delta <= QUOTA_MAX_BACKOFF_MS) {
|
|
659
|
+
return { delayMs: delta, resetsAt: chosen.resetsAt, source: 'rate_limit_info' };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const idx = Math.min(Math.max(0, retryCount), QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
|
|
664
|
+
const backoffMinutes = QUOTA_FALLBACK_LADDER_MINUTES[idx];
|
|
665
|
+
return { delayMs: backoffMinutes * 60 * 1000, source: 'exponential_fallback' };
|
|
666
|
+
}
|
|
667
|
+
/** @internal Test-only. */
|
|
668
|
+
export function _test_setRateLimitInfo(workspaceId, info) {
|
|
669
|
+
latestRateLimitInfo.set(workspaceId, info);
|
|
670
|
+
}
|
|
526
671
|
function handleQuota(workspaceId, _agentSessionId) {
|
|
527
672
|
try {
|
|
528
673
|
updateWorkspaceStatus(workspaceId, 'quota');
|
|
@@ -533,16 +678,19 @@ function handleQuota(workspaceId, _agentSessionId) {
|
|
|
533
678
|
// The quota state is already signalled by the `error { category: 'quota' }`
|
|
534
679
|
// AgentEvent that triggered this handler. No legacy `agent:status { quota }`
|
|
535
680
|
// emit needed.
|
|
536
|
-
//
|
|
681
|
+
// Prefer the actual resetsAt from the last rate_limit event; fall back to
|
|
682
|
+
// the 15/30/60min exponential schedule when that info isn't usable.
|
|
537
683
|
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
538
|
-
const
|
|
539
|
-
const backoffMs =
|
|
684
|
+
const { delayMs, resetsAt, source } = computeQuotaBackoffMs(workspaceId, retryCount);
|
|
685
|
+
const backoffMs = delayMs;
|
|
540
686
|
retryCounts.set(workspaceId, retryCount + 1);
|
|
541
687
|
// Surface the backoff schedule as an ephemeral event so the UI can display
|
|
542
688
|
// retry count / wait time without polluting the persistent event log.
|
|
543
689
|
emitEphemeral(workspaceId, 'agent:quota-backoff', {
|
|
544
690
|
retryCount: retryCount + 1,
|
|
545
|
-
backoffMinutes,
|
|
691
|
+
backoffMinutes: Math.round(delayMs / 60_000),
|
|
692
|
+
resetsAt,
|
|
693
|
+
source,
|
|
546
694
|
});
|
|
547
695
|
const timer = setTimeout(() => {
|
|
548
696
|
backoffTimers.delete(workspaceId);
|
|
@@ -552,8 +700,13 @@ function handleQuota(workspaceId, _agentSessionId) {
|
|
|
552
700
|
return;
|
|
553
701
|
}
|
|
554
702
|
try {
|
|
555
|
-
|
|
556
|
-
|
|
703
|
+
if (freshWs.autoLoop) {
|
|
704
|
+
autoLoopService.onQuotaBackoffExpired(workspaceId);
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
|
|
708
|
+
startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
|
|
709
|
+
}
|
|
557
710
|
}
|
|
558
711
|
catch (err) {
|
|
559
712
|
console.error(`[orchestrator] Quota retry for workspace '${workspaceId}' failed:`, err);
|
|
@@ -597,4 +750,4 @@ export function _runWatchdogForTest() {
|
|
|
597
750
|
runWatchdog();
|
|
598
751
|
}
|
|
599
752
|
/** Test-only export. Not part of the public module API. */
|
|
600
|
-
export const __test__ = { handleEvent };
|
|
753
|
+
export const __test__ = { handleEvent, handleQuota };
|