Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains by KrisBraun · Pull Request #215 · plotday/plot · GitHub
Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains by KrisBraun · Pull Request #215 · plotday/plot · GitHub
Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains by KrisBraun · Pull Request #215 · plotday/plot · GitHub
Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains by KrisBraun · Pull Request #215 · plotday/plot · GitHub
Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains by KrisBraun · Pull Request #215 · plotday/plot · GitHub
Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains by KrisBraun · Pull Request #215 · plotday/plot · GitHub
Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains by KrisBraun · Pull Request #215 · plotday/plot · GitHub
Skip to content

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains - #215

Merged
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks
Jun 22, 2026
Merged

feat(twister): durable recurring tasks — scheduleRecurring + migrate all connector chains#215
KrisBraun merged 18 commits into
mainfrom
durable-recurring-tasks

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Problem

Self-rescheduling connector task chains (watch/subscription renewals, polling loops, self-heal) could die permanently and silently. The continuation of a periodic chain lived only in (a) a transient RUN_QUEUE message and (b) the connector's own "schedule the next occurrence" call — so any single broken hand-off killed the whole chain: a queue retry-exhaustion drop during a DB storm, a twist suspension, a deploy/eviction between fire and reschedule, or a callback that threw before rescheduling. A Gmail connection's hourly self-heal stopped ~June 4 and never restarted; sync flat-lined 18+ days with no error anywhere (a vanished chain is silent).

Fix (this PR — the SDK + connector side)

A new durable scheduleRecurring(key, callback, { intervalMs, firstRunAt? }) primitive whose cadence is owned by the platform (the CallbacksState Durable Object re-arms the next occurrence at fire time, independent of the queue hand-off). A dropped/suspended/crashed run now loses at most one beat, never the chain. intervalMs is a safety ceiling (max gap); firstRunAt sets a precise earlier fire (e.g. renew at expiry−24h) — the callback no longer needs to reschedule itself.

This PR contains:

  • twister:Tasks.scheduleRecurring abstract + Twist/Tool base-class convenience delegators; changeset (@plotday/twister minor). scheduleTask JSDoc narrowed to one-shot keyed.
  • All 13 connectors migrated off the fragile patterns:
    • Bare-runTask + manual-token self-heal/renewal (the incident form): gmail, outlook-mail — token bookkeeping removed, upgrade() re-asserts on deploy.
    • Keyed scheduleTask renewals/polls/daily-syncs: google-calendar, google-drive, google-chat, outlook-calendar, airtable, jira, apple-calendar, asana, google-tasks, slack, ms-teams.
  • SDK docs (connectors/AGENTS.md, RUNTIME.md, TOOLS_GUIDE.md) now recommend scheduleRecurring for recurring chains.

Companion PR

The runtime side — the DO recurring_interval_ms column + ceiling clamp, the alarm() advance-instead-of-delete, the api scheduleRecurring impl, a RUN_QUEUE dead-letter queue, and a backstop reconcile sweep — lands in a follow-up PR on plotday/core after this merges (it bumps the submodule pointer to this branch's merged commit).

Testing

  • Every connector: pnpm build + pnpm exec tsc --noEmit clean.
  • pnpm validate-changesets passes.
  • Reviewed end-to-end (whole-branch review); two cadence-ceiling bugs (airtable/asana polling too frequently) caught and fixed in 265f1aa.

🤖 Generated with Claude Code

Also adds scheduleRecurring() convenience helper to Twist and Tool base
classes (mirroring scheduleTask/cancelScheduledTask), so connectors and
tools can call this.scheduleRecurring() directly without going through
this.tools.tasks.
…l cadence
The migration set intervalMs (the safety ceiling) below the connector's actual
POLL_INTERVAL_MS (airtable 6h, asana 5min), so the create() clamp
min(firstRunAt, now+intervalMs) pinned the effective cadence to 60s — polling
360x/5x too often. Set the ceiling to POLL_INTERVAL_MS so the intended cadence
is honored.
@KrisBraun
KrisBraun merged commit b2d19b7 into mainJun 22, 2026
1 check passed
@KrisBraun
KrisBraun deleted the durable-recurring-tasks branch June 22, 2026 16:57
@plot-app-builderplot-app-builderBot mentioned this pull request Jun 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun