Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a4d1e57
feat: workspace rules — proposal card, review notices, and approved r…
saravmajestic Oct 27, 2026
51047e7
fix: workspace rules — address review findings on sync, proposals, ca…
saravmajestic Oct 28, 2026
f3c7187
fix: workspace rules — the card offers one team action: send (member)…
saravmajestic Oct 28, 2026
0b70df0
feat: workspace rules — show the proposed-rule card inline above the …
saravmajestic Oct 28, 2026
4a01e48
fix: workspace rules — the card's keys no longer take a dialog's keys…
saravmajestic Oct 28, 2026
5fd1b68
fix: workspace rules — `/rules` Sync now and the learning toggle clos…
saravmajestic Oct 28, 2026
6d12db3
docs: learn produces rules — call the learned entries "rules" in lear…
saravmajestic Oct 28, 2026
c581406
fix: workspace rules — address the #1605 peer and bot reviews
saravmajestic Oct 29, 2026
61e2ea4
Merge remote-tracking branch 'origin/main' into feat/workspace-rules
saravmajestic Oct 29, 2026
7f872ef
fix: workspace rules — address the self-review findings
saravmajestic Oct 29, 2026
40ee90e
fix: workspace rules — "remember …" decides memory vs team rule which…
saravmajestic Oct 29, 2026
1e3fedc
feat: workspace rules — telemetry for every rule action
saravmajestic Oct 29, 2026
d9737df
fix: workspace rules — address the latest review round
saravmajestic Oct 29, 2026
d1c1c48
fix: workspace rules — address the third review round
saravmajestic Oct 29, 2026
435bf1b
Merge remote-tracking branch 'origin/main' into feat/workspace-rules
saravmajestic Oct 29, 2026
0d4bfd4
fix: workspace rules — fourth review round and self-review, as one pass
saravmajestic Oct 29, 2026
bddd5ed
test: workspace rules — "Not a rule" suppresses a topic for 30 days, …
saravmajestic Oct 29, 2026
9ef7382
fix: workspace rules — review round five and the full-feature review
saravmajestic Oct 30, 2026
f5a35a9
fix: workspace rules — review round six
saravmajestic Oct 30, 2026
8d83a1a
fix: workspace rules — full-feature review round
saravmajestic Oct 30, 2026
2758e4a
fix: workspace learning setting ignored when workspaces are off
saravmajestic Oct 30, 2026
3f2f127
fix: bootstrap keeps a candidate that changed while its rules were sent
saravmajestic Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
306 changes: 165 additions & 141 deletions docs/docs/configure/learn.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ We collect the following categories of events:
| `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `altimate_base_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). |
| `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. |
| `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. |
| `rule_proposal_shown` | A proposed-rule card was shown (or shown again for the same wording, `repeat`) — what raised it (`remember`, `shown_rule`, `recurrence`), the kind of change, whether it is a "Save as" choice, linked/owner, the buttons offered, whether it weakens verification, evidence session count, whether it came from a correction, how many paths it applies to, and its length. The card id is random. No rule text, quotes, paths or globs. |
| `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `refused` by the workspace, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`, or `saved_unanswered` when a card the person asked for closed unanswered and was saved as before cards), wording edits so far, and time since the card was shown. No rule text. |
| `rule_drafted` | One model call that drafted a proposal after a turn (it runs in the background) — the path (`shown_rule` or `recurrence`), `ok` or `failed`, input and output tokens, estimated cost and duration. No wording or correction text. |
| `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `no_renderer` (no attached client shows cards; the corrections are kept for a later check), `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`. The path checked (`shown_rule` or `recurrence`) is omitted when the check stopped before choosing one. |
| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's), or its request could not be kept on disk: too many requested cards are already open, or the write failed (`requests_full`). No text. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: requests_full now also covers a failed write of the request file, so the telemetry bucket mixes capacity limits with disk errors. Consider a distinct reason such as request_failed for the write path in remember.ts:260, and update this row and the enum in telemetry/index.ts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/docs/reference/telemetry.md, line 81:

<comment>`requests_full` now also covers a failed write of the request file, so the telemetry bucket mixes capacity limits with disk errors. Consider a distinct reason such as `request_failed` for the write path in `remember.ts:260`, and update this row and the enum in `telemetry/index.ts`.</comment>

<file context>
@@ -78,7 +78,7 @@ We collect the following categories of events:
 | `rule_drafted` | One model call that drafted a proposal after a turn (it runs in the background) — the path (`shown_rule` or `recurrence`), `ok` or `failed`, input and output tokens, estimated cost and duration. No wording or correction text. |
 | `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `no_renderer` (no attached client shows cards; the corrections are kept for a later check), `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`. The path checked (`shown_rule` or `recurrence`) is omitted when the check stopped before choosing one. |
-| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's), or too many requested cards are already open (`requests_full`). No text. |
+| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's), or its request could not be kept on disk: too many requested cards are already open, or the write failed (`requests_full`). No text. |
 | `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and no proposal this run tried to send was kept queued; proposals beyond one push's batch can still be waiting, and proposals the workspace refused still count as `ok`, under `refused`), `failed` (a failed pull, attempted proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, counts of rules, pending reviews, proposals sent, refused and still queued, review notices and usage items, and whether a retried "Approve now" failed (`approval_failed`). No rule text, error text, URLs or workspace names. |
 | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. |
</file context>

| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and no proposal this run tried to send was kept queued; proposals beyond one push's batch can still be waiting, and proposals the workspace refused still count as `ok`, under `refused`), `failed` (a failed pull, attempted proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, counts of rules, pending reviews, proposals sent, refused and still queued, review notices and usage items, and whether a retried "Approve now" failed (`approval_failed`). No rule text, error text, URLs or workspace names. |
| `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. |
| `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it (`organization`, `you`, `workspace` or `default`). |
| `rules_command` | `/rules` opened in a client (`view`), or a `learn` subcommand run (its name only, never its arguments). |

Each event includes a timestamp, session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and a machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and not derived from your hardware or identity). When the machine is signed in to Altimate, each event also carries `user_id` and `tenant`, described under [User Identification](#new-user-identification).

Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ export function truthy(key: string) {
return value === "true" || value === "1"
}

// altimate_change start — the one fail-closed kill-switch parse, shared with readers that take an explicit environment
/** A kill switch fails closed: any value but empty, `0` or `false` turns the feature off, so `yes` or `on` cannot leave
* it running while the operator believes it is off. */
export function killSwitchSet(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase()
return normalized !== undefined && normalized !== "" && normalized !== "0" && normalized !== "false"
}
// altimate_change end

/** An Effect `Config` boolean with the same documented-name-first rule, for the flags
* that are resolved through the ambient ConfigProvider rather than `process.env`. The
* documented value is read as a string and judged by `truthy`'s rule, so a set-but-invalid
Expand Down Expand Up @@ -122,10 +131,7 @@ export const Flag = {
// runtime `--` middleware can flip it between plugin activation and command execution.
// Unrelated to OPENCODE_EXPERIMENTAL_WORKSPACES (multi-instance control plane).
get ALTIMATE_DISABLE_WORKSPACE() {
// A kill switch fails closed: any value but empty, `0` or `false` turns workspaces off, so `yes`
// or `on` cannot leave them running while the operator believes they are off.
const value = env("ALTIMATE_DISABLE_WORKSPACE")?.trim().toLowerCase()
return value !== undefined && value !== "" && value !== "0" && value !== "false"
return killSwitchSet(env("ALTIMATE_DISABLE_WORKSPACE"))
},
/**
* Workspace precedence escape hatch, set by `--integrations=local`. When on, the
Expand Down
14 changes: 7 additions & 7 deletions packages/opencode/src/altimate/learn/auto-promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,15 @@ export async function voidAutoOwnership(root: string, name: string): Promise<voi
state = await readAutoPromoteState(root, name)
} catch (error) {
// Its marks could become readable again later, so a person action must not go ahead without voiding them.
throw new Error(`Cannot read ${autoPromoteStateFile(root, name)} to record that lessons are no longer automatic ` +
throw new Error(`Cannot read ${autoPromoteStateFile(root, name)} to record that rules are no longer automatic ` +
`(${redactSecrets(errText(error))}); nothing was changed. Fix the file's permissions or delete it, then retry.`)
}
if (Object.keys(state.auto).length === 0 && state.approvedHash === undefined) return
const { approvedHash: _voided, ...rest } = state
try {
await writeState(root, name, { ...rest, auto: {} })
} catch (error) {
throw new Error(`Cannot record that lessons are no longer automatic in ${autoPromoteStateFile(root, name)} ` +
throw new Error(`Cannot record that rules are no longer automatic in ${autoPromoteStateFile(root, name)} ` +
`(${redactSecrets(errText(error))}); nothing was changed. Fix the file's permissions or delete it, then retry.`)
}
}
Expand Down Expand Up @@ -244,22 +244,22 @@ export async function autoPromote(input: AutoPromoteInput): Promise<AutoPromoteR
if (input.previousCandidate) {
const earlier = lessonChanges(approved, input.previousCandidate)
if (earlier.added.length + earlier.edited.length + earlier.removed.length)
return held("the candidate already had lesson changes waiting for review")
return held("the candidate already had rule changes waiting for review")
}
const { added, edited, removed } = lessonChanges(approved, candidate)
const changed = [...added, ...edited]
if (changed.length + removed.length === 0) return held("no lesson text changed (counter updates only)")
if (changed.length + removed.length === 0) return held("no rule text changed (counter updates only)")
// Gate: verification-weakening lessons always need a person. Unchanged flagged lessons were already approved.
const flagged = changed.filter((lesson) => verificationWarning(lesson.text))
if (flagged.length) return held(`flagged lesson ${ids(flagged)} mentions skipping or weakening verification`)
if (flagged.length) return held(`flagged rule ${ids(flagged)} mentions skipping or weakening verification`)
// Gate: a person's approval is never undone automatically.
const auto = autoPromotedIds(state, approved)
const person = [...edited, ...removed].filter((lesson) => !auto.has(lesson.id))
if (person.length) return held(`it would edit or remove person-approved lesson ${ids(person)}`)
if (person.length) return held(`it would edit or remove person-approved rule ${ids(person)}`)
// Gate: size.
const count = changed.length + removed.length
if (count > input.limits.auto_promote_max_changes)
return held(`${count} lesson changes exceed learn.auto_promote_max_changes (${input.limits.auto_promote_max_changes})`)
return held(`${count} rule changes exceed learn.auto_promote_max_changes (${input.limits.auto_promote_max_changes})`)
// Gate: rate. Reservations count; timestamps in the future count, so a clock change cannot reopen the window.
const recent = state.promotions.filter((promotion) => Date.parse(promotion.at) > now - DAY_MS)
if (recent.length >= input.limits.auto_promote_daily)
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/learn/auto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function describeOutcome(
): string {
const head = `learn: ${signals} signal${signals === 1 ? "" : "s"} -> ${summary}`
if (promotion?.status === "promoted")
return `${head}; auto-promoted${promotion.archived ? ` (previous lessons archived as v${promotion.archived})` : ""}. Undo with \`altimate-code learn rollback\`` +
return `${head}; auto-promoted${promotion.archived ? ` (previous rules archived as v${promotion.archived})` : ""}. Undo with \`altimate-code learn rollback\`` +
(promotion.warning ? ` (warning: ${promotion.warning})` : "")
if (!candidate) return head
if (promotion?.status === "held")
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/altimate/learn/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export async function bootstrap(options: BootstrapOptions, deps: BootstrapDeps):
deps.out(`Signals found: ${all.length} (${all.filter((s) => s.kind === "user_correction").length} corrections, ${all.filter((s) => s.kind === "tool_retry").length} tool failures; ${all.length - plans.reduce((n, p) => n + p.signals.length, 0)} pending).`)
deps.out(`Model/provider: ${label}. Estimated input tokens: ${estimatedInput} for up to ${estimatedReflections} reflection(s), excluding candidate growth.`)
deps.out(`Limits: ${limit} sessions, ${maxReflections} reflections, ${maxSeconds}s total after confirmation.`)
deps.out(model ? "Bootstrap sends redacted excerpts of past sessions to this model and stages candidate lessons only."
deps.out(model ? "Bootstrap sends redacted excerpts of past sessions to this model and stages candidate rules only."
: "Bootstrap imports signals locally without model calls.")
if (options.dryRun) {
for (const signal of all) deps.out(`[${signal.sessionID}] [${signal.kind}] ${Signals.clipSignalText(signal.text)}`)
Expand Down Expand Up @@ -338,7 +338,7 @@ export async function bootstrap(options: BootstrapOptions, deps: BootstrapDeps):
}
}
}
deps.out(`Bootstrap summary: ${summary.signalsFound} signals found, ${summary.signalsAdded} added; ${summary.reflectionsRun} reflections run; candidate lessons: ${summary.candidatesAdded} added, ${summary.candidatesEdited} edited.`)
deps.out(`Bootstrap summary: ${summary.signalsFound} signals found, ${summary.signalsAdded} added; ${summary.reflectionsRun} reflections run; candidate rules: ${summary.candidatesAdded} added, ${summary.candidatesEdited} edited.`)
deps.out(`Tokens${summary.tokensEstimated ? " (estimated)" : ""}: ${summary.inputTokens} input, ${summary.outputTokens} output. Estimated cost: $${summary.estimatedCost.toFixed(6)}.`)
if (!ready() || stop || (await Signals.readSignalsSnapshot(options.root, name)).some((s) => s.source === "bootstrap" && s.status === "open"))
deps.out("Unfinished signals remain queued; re-run `learn bootstrap` to continue.")
Expand Down
67 changes: 58 additions & 9 deletions packages/opencode/src/altimate/learn/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,24 @@ export class Capture {

// Keep every active capture drainable when more than one instance is open.
const active = new Set<Capture>()
/** Instance directories whose capture runs, whoever started it. */
const running = new Set<string>()

/** Whether capture runs for the instance at `directory`. */
export function captureRunning(directory: string): boolean {
return running.has(directory)
}

// altimate_change start — workspace rules: a per-project gate the rules service sets from the full learning decision
// (the person's /rules choice can turn learning off while config still has learn.capture on). No gate: record.
const gates = new Map<string, () => Promise<boolean>>()

/** Set (or clear, with undefined) whether corrections are recorded for the project at `directory`. */
export function setCaptureGate(directory: string, gate: (() => Promise<boolean>) | undefined) {
if (gate) gates.set(directory, gate)
else gates.delete(directory)
}
// altimate_change end

export async function flushCapture() {
await Promise.all([...active].map((capture) => capture.flush()))
Expand All @@ -272,17 +290,36 @@ async function subscribeWhenReady<T>(subscribe: () => T): Promise<T> {
* Starts capture for one instance when enabled. Called once from instance bootstrap inside the instance
* context. Never throws.
*/
export async function startCapture(ctx: { directory: string; worktree: string }): Promise<void> {
// altimate_change start — returns a stop function once capture runs (workspace rules stop it when learning turns off)
export async function startCapture(
ctx: { directory: string; worktree: string },
options: { force?: boolean } = {},
): Promise<(() => Promise<void>) | undefined> {
// altimate_change end
try {
const root = ctx.worktree !== "/" ? ctx.worktree : ctx.directory
const { Config } = await import("@/config/config")
if (!captureEnabled((await Config.get()).learn)) return
// altimate_change start — `force`: workspace rules turned learning on for this project (the person set nothing)
if (!options.force && !captureEnabled((await Config.get()).learn)) return
// altimate_change end
const { Bus } = await import("@/bus")
const { MessageV2 } = await import("../../session/message-v2")
const { Session } = await import("../../session")
const { SessionID } = await import("../../session/schema")
const capture = new Capture({
record: (signal) => appendSignal(root, signal),
record: async (signal) => {
// Fails closed: a gate that cannot decide (unreadable rules state) must not record a correction the person
// may have turned off. It throws rather than returns, so the correction is not marked recorded and a later
// update of the message can try again; `track` logs it.
const gate = gates.get(ctx.directory)
if (gate) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: No test covers this gate branch. Add a capture test that sets a gate which rejects, confirms no signal is written, and confirms a later onPart update records the correction once the gate resolves to true. The fail-closed behavior and the new retry semantics depend on this path.

(Based on your team's feedback about failing closed on capture gate errors.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/learn/capture.ts, line 308:

<comment>No test covers this gate branch. Add a capture test that sets a gate which rejects, confirms no signal is written, and confirms a later `onPart` update records the correction once the gate resolves to true. The fail-closed behavior and the new retry semantics depend on this path.

(Based on your team's feedback about failing closed on capture gate errors.) </comment>

<file context>
@@ -302,9 +302,15 @@ export async function startCapture(
+        // update of the message can try again; `track` logs it.
         const gate = gates.get(ctx.directory)
-        if (gate && !(await gate().catch(() => false))) return
+        if (gate) {
+          const on = await gate().catch((error: unknown) => {
+            throw new Error(`learning decision unavailable, correction not recorded: ${error instanceof Error ? error.message : String(error)}`)
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added: test/altimate/learn/capture-bus.test.ts covers a gate that cannot decide (no signal is written and the correction stays unrecorded) and a later update that records it.

const on = await gate().catch((error: unknown) => {
throw new Error(`learning decision unavailable, correction not recorded: ${error instanceof Error ? error.message : String(error)}`)
})
if (!on) return
}
return appendSignal(root, signal)
},
turnStart: async (sessionID, messageID) => {
const messages = await Session.messages({ sessionID: SessionID.make(sessionID) })
return correctedTurnStart(turnMessages(messages), messageID)
Expand All @@ -294,15 +331,25 @@ export async function startCapture(ctx: { directory: string; worktree: string })
},
})
active.add(capture)
running.add(ctx.directory)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: running is set before setup can fail, so one failed start blocks capture for the process

This runs before registerDisposer and the two subscribeWhenReady calls, and the catch below returns undefined without removing it. If a subscription throws (the bus is still building, the exact case subscribeWhenReady exists for), captureRunning keeps reporting true for the directory, so followLearning in service.ts takes the running branch, only installs the gate, and never starts capture again: correction capture stays silently dead until the instance is disposed, where before it was retried on the next refresh. Add to running only after the subscriptions succeed, or clean up running (and active) in the catch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: running is set before either Bus.subscribe finishes. If subscribeWhenReady exhausts its retries, startCapture returns without clearing this entry, so RulesService.followLearning treats capture as active and skips restarting it. Set the flag only after both subscriptions succeed and roll back setup on failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/learn/capture.ts, line 334:

<comment>`running` is set before either `Bus.subscribe` finishes. If `subscribeWhenReady` exhausts its retries, `startCapture` returns without clearing this entry, so `RulesService.followLearning` treats capture as active and skips restarting it. Set the flag only after both subscriptions succeed and roll back setup on failure.</comment>

<file context>
@@ -324,6 +331,7 @@ export async function startCapture(
       },
     })
     active.add(capture)
+    running.add(ctx.directory)
     const subscriptions: Array<() => void> = []
     // altimate_change start — one stop path for disposal and for workspace rules
</file context>

const subscriptions: Array<() => void> = []
// altimate_change start — one stop path for disposal and for workspace rules
// One stop, shared: a second caller (the instance disposer overlapping a settings change) waits for the
// same flush instead of returning before the pending signal writes land.
let stopping: Promise<void> | undefined
const stopCapture = () =>
(stopping ??= (async () => {
for (const stop of subscriptions) stop()
await capture.flush()
await flushWrites()
active.delete(capture)
running.delete(ctx.directory)
unregister()
})())
const unregister = registerDisposer(async (directory) => {
if (directory !== ctx.directory) return
for (const stop of subscriptions) stop()
await capture.flush()
await flushWrites()
active.delete(capture)
unregister()
if (directory === ctx.directory) await stopCapture()
})
// altimate_change end
subscriptions.push(
await subscribeWhenReady(() => Bus.subscribe(MessageV2.Event.Updated, (evt) => capture.onMessage(evt.properties.info))),
)
Expand All @@ -312,7 +359,9 @@ export async function startCapture(ctx: { directory: string; worktree: string })
),
)
log.info("learn capture started", { root })
return stopCapture
} catch (e) {
log.warn("learn capture not started", { error: e instanceof Error ? e.message : String(e) })
return undefined
}
}
8 changes: 7 additions & 1 deletion packages/opencode/src/altimate/learn/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// altimate_change - new file
import { killSwitchSet } from "@opencode-ai/core/flag/flag"

interface LearnConfig {
enabled?: boolean
Expand All @@ -8,12 +9,17 @@ interface LearnConfig {
model?: string
}

function booleanFlag(value: string | undefined): boolean | undefined {
export function booleanFlag(value: string | undefined): boolean | undefined {
if (value === "1" || value?.toLowerCase() === "true") return true
if (value === "0" || value?.toLowerCase() === "false") return false
return undefined
}

/** Workspaces are on unless the `ALTIMATE_DISABLE_WORKSPACE` kill switch is set (`Flag`'s parse, with `env` explicit). */
export function workspacesOn(env: NodeJS.ProcessEnv = process.env): boolean {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
return !killSwitchSet(env["ALTIMATE_DISABLE_WORKSPACE"])
}

/** The global switch defaults on; env overrides config in both directions. */
export function learnEnabled(cfg?: { enabled?: boolean }, env: NodeJS.ProcessEnv = process.env): boolean {
return booleanFlag(env["ALTIMATE_LEARN"]) ?? cfg?.enabled ?? true
Expand Down
Loading
Loading