Skip to content

Commit 9e31644

Browse files
authored
Merge branch 'main' into florian/chore/event-log
2 parents 7fb0f10 + f7a8243 commit 9e31644

67 files changed

Lines changed: 18310 additions & 525 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.test

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID=coupon_test_kiloclaw_earlybird
7979
STRIPE_KILOCLAW_COMMIT_PRICE_ID=price_test_kiloclaw_commit
8080
STRIPE_KILOCLAW_STANDARD_PRICE_ID=price_test_kiloclaw_standard
8181
STRIPE_KILOCLAW_STANDARD_FIRST_MONTH_COUPON_ID=coupon_test_kiloclaw_standard_first_month
82-
STRIPE_KILOCLAW_BILLING_START=
8382

8483
# Stripe publishable key for client-side 3DS authentication
8584
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_invalid_mock_key

.specs/kiloclaw-billing.md

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
Draft -- generated from branch `jdp/kiloclaw-billing` on 2026-03-13.
66
Updated 2026-03-19 -- pricing and trial duration changes.
7+
Updated 2026-03-20 -- promotional codes and introductory pricing.
78

89
## Conventions
910

@@ -42,6 +43,22 @@ lapses, with email notifications at each stage.
4243
7. The system MUST fail with an error at checkout time if a required
4344
plan price identifier is not configured.
4445

46+
### Standard Plan Introductory Pricing
47+
48+
1. New standard plan subscribers who do not have a prior canceled paid
49+
subscription MUST receive an introductory price for their first
50+
billing period. A canceled trial does not count as a prior paid
51+
subscription. Returning subscribers with a previously canceled paid
52+
subscription MUST receive the regular standard price.
53+
2. The system MUST automatically transition introductory-price
54+
subscribers to the regular standard price at the end of the
55+
introductory billing period.
56+
3. The automatic price transition MUST be transparent to the user: it
57+
MUST NOT appear as a pending plan switch in billing status and MUST
58+
NOT prevent the user from initiating a plan switch or canceling.
59+
4. Failure to set up the automatic price transition during subscription
60+
creation MUST NOT block checkout completion.
61+
4562
### Trial Eligibility and Creation
4663

4764
1. A trial MUST only be created automatically when a user provisions an
@@ -81,21 +98,18 @@ lapses, with email notifications at each stage.
8198
2. The system MUST allow checkout when the existing subscription status
8299
is trialing or canceled.
83100
3. The system MUST verify with the payment provider that no subscription
84-
in active or trialing (delayed-billing) status already exists for the
85-
customer before creating a new checkout session, to guard against
86-
concurrent checkouts. This check does not cover provider-side
87-
subscriptions in past-due status.
88-
4. The system MUST NOT allow promotional codes for either plan.
89-
5. The system MUST apply a provider-configured first-month discount
90-
coupon when creating a standard plan checkout session.
91-
6. When a configurable billing start date is set and is in the future,
92-
the system MUST create the subscription with a delayed billing period
93-
that begins on that date.
94-
7. When the billing start date is unset or is in the past, the system
95-
MUST start billing immediately with no delayed period.
96-
8. The system SHOULD include referral tracking data in checkout sessions
101+
in active or trialing status already exists for the customer before
102+
creating a new checkout session, to guard against concurrent
103+
checkouts. This check does not cover provider-side subscriptions in
104+
past-due status.
105+
4. The system MUST allow promotional codes on checkout for both plans.
106+
5. For standard plan checkout, the system MUST use the introductory
107+
price when the user has no prior canceled paid subscription, and the
108+
regular price when the user has a previously canceled paid
109+
subscription (see Standard Plan Introductory Pricing).
110+
6. The system SHOULD include referral tracking data in checkout sessions
97111
when a referral cookie is present.
98-
9. The system SHOULD attempt to expire open checkout sessions tagged as
112+
7. The system SHOULD attempt to expire open checkout sessions tagged as
99113
KiloClaw before creating a new checkout session, so users who
100114
abandoned a previous checkout can start fresh. Expiration is
101115
best-effort: errors from the payment provider (e.g. the session was
@@ -111,9 +125,8 @@ lapses, with email notifications at each stage.
111125
the subscription to the standard plan.
112126
2. When a commit subscription is created, the system MUST record a
113127
commit-period end date six calendar months from the billing start.
114-
When a delayed-billing period is configured, the six months MUST
115-
start from the delayed-billing end date, not from subscription
116-
creation.
128+
For pre-launch subscriptions that had a delayed-billing trial_end,
129+
the six months starts from that trial boundary.
117130
3. When a subscription update is received and the commit-period end
118131
date is in the past, the system MUST extend it by six calendar
119132
months from the previous boundary, keeping the subscription on the
@@ -145,21 +158,35 @@ lapses, with email notifications at each stage.
145158
the commit-period end date to six calendar months from the
146159
transition date.
147160
8. The system MUST allow cancellation of user-initiated plan switches.
161+
9. The system MUST reject a plan switch if a user-initiated plan switch
162+
is already pending.
163+
10. If an automatic price transition is pending when the user requests a
164+
plan switch, the system MUST replace the automatic transition with
165+
the user's requested switch.
166+
11. After canceling a user-initiated plan switch, if the subscription is
167+
still on the introductory price, the system MUST restore the
168+
automatic price transition. Failure to restore the transition MUST
169+
NOT prevent the switch cancellation from succeeding.
148170

149171
### Cancellation and Reactivation
150172

151173
1. The system MUST reject a cancellation request if no active
152174
subscription with a payment provider ID exists.
153175
2. The system MUST reject a cancellation request if cancellation is
154176
already pending.
155-
3. When canceling a subscription that has a pending schedule, the system
156-
MUST release the schedule before setting the cancel-at-period-end
157-
flag.
177+
3. When canceling a subscription that has a pending schedule — whether
178+
a user-initiated plan switch or an automatic price transition — the
179+
system MUST release the schedule before setting the
180+
cancel-at-period-end flag.
158181
4. Cancellation MUST NOT terminate access immediately; access MUST
159182
continue until the current billing period ends.
160183
5. The system MUST allow reactivation of a subscription that is pending
161184
cancellation.
162185
6. On reactivation, the system MUST clear the cancel-at-period-end flag.
186+
7. On reactivation, if the subscription is still on the introductory
187+
price, the system MUST restore the automatic price transition.
188+
Failure to restore the transition MUST NOT prevent the reactivation
189+
from succeeding.
163190

164191
### Billing Lifecycle Background Job
165192

@@ -169,6 +196,9 @@ lapses, with email notifications at each stage.
169196
2. Each sweep in the background job MUST process users independently;
170197
a failure for one user MUST NOT prevent processing of other users.
171198
3. All errors during sweep processing MUST be captured for monitoring.
199+
4. The background job MUST detect active subscriptions on the
200+
introductory price that have no automatic price transition pending
201+
and MUST set up the missing transition.
172202

173203
### Trial Expiry Warnings
174204

@@ -258,8 +288,9 @@ lapses, with email notifications at each stage.
258288
### Payment Provider Status Mapping
259289

260290
1. When the payment provider reports a subscription as "trialing"
261-
(delayed billing), the system MUST map this to active status
262-
internally, since delayed billing is not a product-level trial.
291+
(e.g. pre-launch delayed billing), the system MUST map this to
292+
active status internally, since delayed billing is not a
293+
product-level trial.
263294
2. When the payment provider reports "incomplete" or "paused" status,
264295
the system MUST map these to terminal statuses (unpaid or canceled
265296
respectively).
@@ -299,6 +330,28 @@ lapses, with email notifications at each stage.
299330

300331
### Changelog
301332

333+
#### 2026-03-21 -- Remove delayed-billing start date
334+
335+
- Removed configurable billing start date (`STRIPE_KILOCLAW_BILLING_START`)
336+
and associated checkout rules (former rules 6–7). New subscriptions now
337+
always bill immediately.
338+
- Pre-launch subscriptions created with a delayed `trial_end` are still
339+
handled correctly; the trialing→active status mapping remains until those
340+
subscriptions transition.
341+
342+
#### 2026-03-20 -- Promotional codes and introductory pricing
343+
344+
Previous values:
345+
346+
- Standard plan first-month discount: coupon applied at checkout
347+
- Promotional codes: not allowed on either plan
348+
349+
New values:
350+
351+
- Standard plan first-month discount: introductory price with automatic
352+
transition to regular price at period end
353+
- Promotional codes: allowed on both plans at checkout
354+
302355
#### 2026-03-19 -- Pricing and trial changes
303356

304357
Previous values:

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,7 @@ When creating or updating a pull request, you **must** follow the PR template in
5252
- Do not leave HTML comments from the template in the final description — replace them with actual content.
5353
- PR descriptions must be accurate and valuable to reviewers. Generic or boilerplate descriptions waste reviewer time.
5454
- Review all commits on the branch (not just the latest) when writing the summary.
55+
56+
## KiloClaw Billing
57+
58+
Before making **any** changes related to KiloClaw billing — including bug fixes, new features, refactors, or reviews — you **must** first read the billing spec at `.specs/kiloclaw-billing.md`. This applies to all code paths that touch billing logic, pricing, invoicing, usage metering, or payment flows.

cloud-agent-next/wrapper/src/connection.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,28 @@ export function createConnectionManager(
273273
return false;
274274
}
275275

276+
function getTerminalErrorText(eventType: string, properties: Record<string, unknown>): string {
277+
const error = properties.error;
278+
if (typeof error === 'string') {
279+
return error;
280+
}
281+
282+
if (isRecord(error)) {
283+
if (typeof error.message === 'string') {
284+
return error.message;
285+
}
286+
287+
const data = error.data;
288+
if (isRecord(data) && typeof data.message === 'string') {
289+
return data.message;
290+
}
291+
292+
return JSON.stringify(error);
293+
}
294+
295+
return `Insufficient credits: ${eventType}`;
296+
}
297+
276298
/**
277299
* Start the SDK event subscription. Runs in the background.
278300
* Replaces the old SSE consumer with a typed event stream from the SDK.
@@ -367,7 +389,7 @@ export function createConnectionManager(
367389

368390
// Terminal error detection
369391
if (isTerminalError(eventType, properties)) {
370-
callbacks.onTerminalError(eventType);
392+
callbacks.onTerminalError(getTerminalErrorText(eventType, properties));
371393
return;
372394
}
373395

cloudflare-gastown/src/dos/Town.do.ts

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,11 @@ export class TownDO extends DurableObject<Env> {
240240
}
241241

242242
private emitEvent(data: Omit<GastownEventData, 'userId' | 'delivery'>): void {
243-
writeEvent(this.env, { ...data, delivery: 'internal', userId: this._ownerUserId });
243+
writeEvent(this.env, {
244+
...data,
245+
delivery: 'internal',
246+
userId: this._ownerUserId,
247+
});
244248
}
245249

246250
/** Build the context object used by the scheduling sub-module. */
@@ -290,7 +294,9 @@ export class TownDO extends DurableObject<Env> {
290294
});
291295
}
292296

293-
return scheduling.dispatchAgent(schedulingCtx, agent, bead, { systemPromptOverride });
297+
return scheduling.dispatchAgent(schedulingCtx, agent, bead, {
298+
systemPromptOverride,
299+
});
294300
},
295301
stopAgent: async agentId => {
296302
await dispatch.stopAgentInContainer(this.env, this.townId, agentId);
@@ -677,7 +683,9 @@ export class TownDO extends DurableObject<Env> {
677683
const townConfig = await this.getTownConfig();
678684
if (!townConfig.kilocode_token || townConfig.kilocode_token !== rigConfig.kilocodeToken) {
679685
console.log(`${TOWN_LOG} configureRig: propagating kilocodeToken to town config`);
680-
await this.updateTownConfig({ kilocode_token: rigConfig.kilocodeToken });
686+
await this.updateTownConfig({
687+
kilocode_token: rigConfig.kilocodeToken,
688+
});
681689
}
682690
}
683691

@@ -1214,10 +1222,14 @@ export class TownDO extends DurableObject<Env> {
12141222
* Return undelivered, non-expired nudges for an agent.
12151223
* Urgent nudges are returned first, then FIFO within same priority.
12161224
*/
1217-
async getPendingNudges(
1218-
agentId: string
1219-
): Promise<
1220-
{ nudge_id: string; message: string; mode: string; priority: string; source: string }[]
1225+
async getPendingNudges(agentId: string): Promise<
1226+
{
1227+
nudge_id: string;
1228+
message: string;
1229+
mode: string;
1230+
priority: string;
1231+
source: string;
1232+
}[]
12211233
> {
12221234
const rows = [
12231235
...query(
@@ -1768,7 +1780,12 @@ export class TownDO extends DurableObject<Env> {
17681780

17691781
/** Build the rig list for mayor agent startup (browse worktree setup on fresh containers). */
17701782
private async rigListForMayor(): Promise<
1771-
Array<{ rigId: string; gitUrl: string; defaultBranch: string; platformIntegrationId?: string }>
1783+
Array<{
1784+
rigId: string;
1785+
gitUrl: string;
1786+
defaultBranch: string;
1787+
platformIntegrationId?: string;
1788+
}>
17721789
> {
17731790
const rigRecords = rigs.listRigs(this.sql);
17741791
return Promise.all(
@@ -1792,7 +1809,10 @@ export class TownDO extends DurableObject<Env> {
17921809
message: string,
17931810
_model?: string,
17941811
uiContext?: string
1795-
): Promise<{ agentId: string; sessionStatus: 'idle' | 'active' | 'starting' }> {
1812+
): Promise<{
1813+
agentId: string;
1814+
sessionStatus: 'idle' | 'active' | 'starting';
1815+
}> {
17961816
const townId = this.townId;
17971817

17981818
let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null;
@@ -1876,7 +1896,10 @@ export class TownDO extends DurableObject<Env> {
18761896
* Called eagerly on page load so the terminal is available immediately
18771897
* without requiring the user to send a message first.
18781898
*/
1879-
async ensureMayor(): Promise<{ agentId: string; sessionStatus: 'idle' | 'active' | 'starting' }> {
1899+
async ensureMayor(): Promise<{
1900+
agentId: string;
1901+
sessionStatus: 'idle' | 'active' | 'starting';
1902+
}> {
18801903
const townId = this.townId;
18811904

18821905
let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null;
@@ -2250,7 +2273,10 @@ export class TownDO extends DurableObject<Env> {
22502273
tasks: Array<{ title: string; body?: string; depends_on?: number[] }>;
22512274
merge_mode?: 'review-then-land' | 'review-and-merge';
22522275
staged?: boolean;
2253-
}): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent | null }> }> {
2276+
}): Promise<{
2277+
convoy: ConvoyEntry;
2278+
beads: Array<{ bead: Bead; agent: Agent | null }>;
2279+
}> {
22542280
// Resolve staged: explicit request wins, otherwise fall back to town config default.
22552281
const townConfig = await this.getTownConfig();
22562282
const isStaged = input.staged ?? townConfig.staged_convoys_default;
@@ -2449,9 +2475,10 @@ export class TownDO extends DurableObject<Env> {
24492475
/**
24502476
* Transition a staged convoy to active: hook agents and begin dispatch.
24512477
*/
2452-
async startConvoy(
2453-
convoyId: string
2454-
): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent | null }> }> {
2478+
async startConvoy(convoyId: string): Promise<{
2479+
convoy: ConvoyEntry;
2480+
beads: Array<{ bead: Bead; agent: Agent | null }>;
2481+
}> {
24552482
const convoy = this.getConvoy(convoyId);
24562483
if (!convoy) throw new Error(`Convoy not found: ${convoyId}`);
24572484
if (!convoy.staged) throw new Error(`Convoy is not staged: ${convoyId}`);
@@ -2994,9 +3021,14 @@ export class TownDO extends DurableObject<Env> {
29943021
const violations = reconciler.checkInvariants(this.sql);
29953022
metrics.invariantViolations = violations.length;
29963023
if (violations.length > 0) {
2997-
console.error(
2998-
`${TOWN_LOG} [reconciler:invariants] town=${townId} ${violations.length} violation(s): ${JSON.stringify(violations)}`
2999-
);
3024+
// Emit as an analytics event for observability dashboards instead
3025+
// of console.error (which spams Workers logs every 5s per town).
3026+
this.emitEvent({
3027+
event: 'reconciler.invariant_violations',
3028+
townId,
3029+
label: violations.map(v => `[${v.invariant}] ${v.message}`).join('; '),
3030+
value: violations.length,
3031+
});
30003032
}
30013033
} catch (err) {
30023034
console.warn(`${TOWN_LOG} [reconciler:invariants] town=${townId} check failed`, err);
@@ -3575,7 +3607,13 @@ export class TownDO extends DurableObject<Env> {
35753607
[]
35763608
),
35773609
];
3578-
const beadCounts = { open: 0, inProgress: 0, inReview: 0, failed: 0, triageRequests: 0 };
3610+
const beadCounts = {
3611+
open: 0,
3612+
inProgress: 0,
3613+
inReview: 0,
3614+
failed: 0,
3615+
triageRequests: 0,
3616+
};
35793617
for (const row of beadRows) {
35803618
const s = `${row.status as string}`;
35813619
const c = Number(row.cnt);

cloudflare-gastown/src/dos/town/agents.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,11 +546,20 @@ export function touchAgent(
546546
activeTools?: string[];
547547
}
548548
): void {
549+
// A heartbeat is proof the agent is alive in the container.
550+
// If the agent's status is 'idle' (e.g. due to a dispatch timeout
551+
// race — see #1358), restore it to 'working'. This prevents the
552+
// reconciler from treating the agent as lost while it's actively
553+
// sending heartbeats.
549554
query(
550555
sql,
551556
/* sql */ `
552557
UPDATE ${agent_metadata}
553558
SET ${agent_metadata.columns.last_activity_at} = ?,
559+
${agent_metadata.columns.status} = CASE
560+
WHEN ${agent_metadata.columns.status} = 'idle' THEN 'working'
561+
ELSE ${agent_metadata.columns.status}
562+
END,
554563
${agent_metadata.columns.last_event_type} = COALESCE(?, ${agent_metadata.columns.last_event_type}),
555564
${agent_metadata.columns.last_event_at} = COALESCE(?, ${agent_metadata.columns.last_event_at}),
556565
${agent_metadata.columns.active_tools} = COALESCE(?, ${agent_metadata.columns.active_tools})

0 commit comments

Comments
 (0)