Skip to content

Commit 975735a

Browse files
committed
feat(cos): heal zombie issues — partial-ship hybrid + issue-reconcile task
Partially-completed issues were left OPEN + `in-progress` after their PR merged, so the claim queue (which skips `in-progress`) never re-picked the remaining scope (e.g. #2220, #2179, #2175). The completion flow was binary (fully-done → close + unlabel) and the branch reconciler is branch-centric, so it never touched the linked issue. Two fixes: - claim-issue / -gitlab / -jira prompts now choose the PR trailer deliberately (Closes for a full ship, Refs + a `## Remaining` section for a partial one) and Phase 7 reconciles the issue with an agent-judged hybrid: close + file a scoped follow-up when the remainder is a separable chunk, else post a done/remaining comment + release the `in-progress` claim so the queue re-picks it (JIRA records remaining scope + files a follow-up ticket). - new opt-in per-app `issue-reconcile` CoS task (sibling to branch-reconcile, disabled by default): a deterministic gh/git scan finds zombie issues (open + in-progress yet with their PR merged and no live claim anywhere) and dispatches a coordinator agent that applies the same hybrid, with an `autoClose` toggle (OFF ⇒ comment + release only) and follow-up dedup so federated peers can't file duplicates. Perpetual drain with a convergence signature. Prompt-version discipline: claim-issue 3→4, -gitlab 2→3, -jira 1→2, outgoing bodies preserved verbatim in PREVIOUS_DEFAULT_PROMPTS, integrity snapshot regenerated. New module server/services/issueReconcile.js + 30 unit tests. Claude-Session: https://claude.ai/code/session_013EZAPV4vq8A1ea6TB45kXq
1 parent cfd1061 commit 975735a

11 files changed

Lines changed: 1117 additions & 25 deletions

File tree

.changelog/NEXT.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
- **Local-model Claude agents now launch in a lean, isolated mode.** CoS agents on the Ollama-backed Claude providers (`claude-ollama`, `claude-ollama-tui`) previously inherited your full personal Claude Code environment — hooks, plugins, MCP servers, global CLAUDE.md — which reliably derailed small local models (a live eval showed qwen3.6:35b answering a session-start hook instead of its task). These sessions now spawn with `--bare --strict-mcp-config` (no hooks, no plugins, no MCP tool schemas, no CLAUDE.md auto-discovery), authenticating through the provider's existing `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` (pointed at your local Ollama), and get the same plain-`git`/`gh` completion workflow OpenCode agents use instead of slashdo commands bare mode can't load. In the same eval, once isolated this way qwen3.6:35b wrote a correct, test-passing helper (verified 46/46) end to end — the failures were the environment, not the model.
1919
- **Every Claude Code agent now separates its task from the PortOS operating contract.** For all Claude providers (TUI and headless CLI, local and hosted), the PortOS coordination boilerplate — worktree branch, completion-sentinel protocol, git/PR workflow — now rides in a real system prompt via `--append-system-prompt-file` (saved beside the agent's `prompt.txt` as `system-prompt.md`), while the pasted/stdin prompt carries only the task. The model weights the contract as standing instructions rather than conversation, and hosted providers get better prompt-cache reuse on the stable system block. The prompt content is byte-identical to before — only its placement changed. Codex, Antigravity, and OpenCode agents (no equivalent flag) keep the combined prompt unchanged.
2020

21+
- **Partially-completed issues no longer become un-claimable "zombies," and a new scheduled task heals any that already exist.** When a claim run shipped only PART of an issue's scope and merged the PR, the issue was left OPEN with its `in-progress` label (and assignee) still on — and since the claim queue treats `in-progress` as "claimed and being worked," the remaining scope was never re-picked and never finished (real cases: #2220, #2179, #2175). The completion flow was binary (fully-done → close + unlabel) with no "partial ship" path, and the branch reconciler is branch-centric so it never touched the linked issue. Two fixes: **(1)** the `claim-issue` / `-gitlab` / `-jira` prompts now choose the PR trailer deliberately (`Closes` for a full ship, `Refs` + a `## Remaining` section for a partial one) and Phase 7 reconciles the issue with an agent-judged hybrid — close + file a scoped follow-up when the remainder is a clean separable chunk, else post a `Done ✓ / Remaining ▢` comment and release the `in-progress` claim so the queue re-picks it (JIRA records remaining scope + files a follow-up ticket). **(2)** a new opt-in per-app `issue-reconcile` CoS task (sibling to `branch-reconcile`, disabled by default) runs a deterministic `gh`/`git` scan for zombie issues — open + `in-progress` yet with their PR merged and no live claim anywhere (no open PR, no local/remote/CoS claim branch, no active agent) — and dispatches a coordinator agent that applies the same hybrid per issue, with an `autoClose` toggle (OFF ⇒ comment + release only) and follow-up dedup so two federated peers can't file duplicates. Perpetual drain with a convergence signature, exactly like branch-reconcile. (`server/services/issueReconcile.js`, `server/services/cosTaskGenerator.js`, `server/services/taskSchedule.js`, `server/lib/cosValidation.js`, `server/services/taskPromptDefaults/`)
22+
2123
## Creative Writing Quality Engine
2224

2325
- **[issue-2165] Editorial review now catches recognizable "AI slop" writing tells automatically.** Four new deterministic editorial checks join the existing 68: a tiered banned-word scan (hard-bans like "delve"/"myriad"/"utilize"; suspicious words like "robust"/"seamless" only flagged when 3+ cluster in one paragraph), fiction-specific AI-tell idioms ("a sense of dread", "couldn't help but", "eyes widened", "a wave of relief washed over him"), rhetorical structural tics ("not just X, but Y", runs of "did not [verb]", punchy three-sentence-in-a-row bursts like "Fast. Precise. Deadly."), and structural burstiness (em-dash overuse, essay-style transition-word openers, mechanically uniform paragraph lengths). Each surfaces as an editorial finding with the exact offending quote, same as the existing prose checks — configurable thresholds and house-style allowlists included.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Issue Reconcile — kill zombie issues, self-heal partial ships
2+
3+
**Date:** 2026-07-06
4+
**Status:** approved → implementing
5+
**Related:** #2220 (closed, follow-up #2241), #2179, #2175 (released); epic tie-in with the branch-reconcile CoS task (#2237/#2240)
6+
7+
## Problem
8+
9+
An issue that is only PARTIALLY completed by a merged PR becomes a **zombie**:
10+
`OPEN` + `in-progress` label + assignee, with its PR already merged. The claim
11+
queue treats `in-progress` as "claimed and being worked" and skips it, so the
12+
remaining scope is never re-claimed and never completed.
13+
14+
Root cause — the completion flow has no "partial completion" state:
15+
16+
1. **`/do:next` Phase 7** (`taskPromptDefaults/prompts.js`) is *binary*: it verifies
17+
the issue auto-closed via a `Closes #NUM` trailer, and if still open closes it +
18+
removes `in-progress`. There is no branch for "I shipped part of the scope." The
19+
only pre-ship escape (`needs-input`, `:558`) releases *without shipping*.
20+
2. **Branch reconciler** (`branchReconcile.js`) is entirely branch-centric — after it
21+
merges an `IN_REVIEW` PR it deletes the branch/worktree but **never touches the
22+
linked issue's label or state**.
23+
24+
So a sensible partial ship with `Refs #NUM` (not `Closes`) merged via the
25+
reconciler/swarm path → zombie. Live examples: #2220, #2179, #2175.
26+
27+
## Design
28+
29+
Agent-judged **hybrid** partial-ship policy, applied in two places.
30+
31+
### Part A — teach the completion flow "partial ship" (future direct claims)
32+
33+
`claim-issue`, `claim-issue-gitlab`, `claim-issue-jira` Phase 5/7:
34+
35+
- Phase 5: the PR body uses `Closes #NUM` only when the PR fully satisfies the
36+
issue; a partial ship uses `Refs #NUM` + a "Remaining" section.
37+
- Phase 7: after merge, decide *did this PR fully satisfy the issue?*
38+
- **Yes** → existing close + remove `in-progress`.
39+
- **No, remainder is a clean separable chunk** → close original (comment:
40+
shipped ✓ / moved to #NEW), file a scoped follow-up (`plan` + inherited area
41+
labels, `Refs #orig`).
42+
- **No, remainder is a continuation of the same scope** → keep open, post
43+
`Done ✓ / Remaining ▢` comment, remove `in-progress` + assignee → re-claimable.
44+
45+
Prompt-version discipline (CLAUDE.md "Distribution model"): bump `PROMPT_VERSIONS`
46+
for all three keys, append the outgoing bodies verbatim to
47+
`PREVIOUS_DEFAULT_PROMPTS`, regenerate `integrity.snapshot.json`.
48+
49+
### Part B — new `issue-reconcile` per-app CoS task (self-heal + reconciler path)
50+
51+
Mirrors the `branch-reconcile` shape:
52+
53+
- **`server/services/issueReconcile.js`** (pure classifier + `gh` gatherers, unit-tested):
54+
scan open + `in-progress` issues; classify each `ZOMBIE` iff a linked **merged**
55+
PR references it (head ref encodes `issue-<num>`, or body mentions `#<num>`) **and**
56+
there is no **live** claim (no open PR / local / remote / `cos/*/issue-<num>/*`
57+
branch for it) **and** no active CoS agent. Emit a `zombieSignature` convergence
58+
guard so the perpetual drain parks instead of looping.
59+
- **`cosTaskGenerator.js`**: deterministic pre-step + dispatch block parallel to the
60+
branch-reconcile block — zombies → dispatch a coordinator agent that reads each
61+
issue+PR and applies the SAME hybrid decision; none → park on `recheckCron`.
62+
- **Registration**: `SELF_IMPROVEMENT_TASK_TYPES`, `DEFAULT_TASK_INTERVALS`
63+
(PERPETUAL, disabled, `recheckCron: '0 4 * * *'`, `taskMetadata.autoClose: true`),
64+
`MANAGED_AGENT_OPTIONS` (lock `useWorktree`/`openPR` off — the coordinator works
65+
over `gh`, no worktree), the task-description map, `PROMPT_VERSIONS`
66+
(`issue-reconcile: 1`), `ALLOWED_TASK_METADATA_KEYS` (`autoClose`), and the new
67+
coordinator prompt body.
68+
- **Disabled by default** → no migration needed (new opt-in task).
69+
- **`autoClose` toggle** (default ON): when OFF the coordinator never closes an
70+
issue, only comments + unlabels — a safety valve for users who prefer one thread
71+
per feature.
72+
73+
**Peer safety** (differs from branch-reconcile, which is local-ref-only): issue
74+
state is shared GitHub state across federated peers (void/null/NaN/undefined).
75+
Close/unlabel are idempotent; the one real race is *filing a duplicate follow-up
76+
from two machines*, so the coordinator dedupes by searching for an existing
77+
follow-up (`Refs #orig` marker) before filing. v1 pre-step is `gh`-only and parks
78+
on non-GitHub apps; GitLab/JIRA siblings are deferred (see PLAN.md).
79+
80+
## Part C — heal the three current zombies (done)
81+
82+
- **#2220** — separable remainder → **closed**, follow-up **#2241** filed.
83+
- **#2179**, **#2175** — continuation → kept open, `Done/Remaining` comment,
84+
`in-progress` + assignee removed.

server/lib/cosValidation.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,16 @@ export const PIPELINE_BEHAVIOR_FLAGS = ['useWorktree', 'openPR', 'simplify', 're
266266
export const MAX_TOTAL_SPAWNS = 5;
267267

268268
// `cleanupMerged` / `openPr` / `resolveConflicts` / `autoMerge` are the
269-
// per-app action toggles for the `branch-reconcile` task type (each ON unless
270-
// explicitly false). They live in the shared task-metadata allowlist — like
271-
// `prAuthorFilter` / `issueAuthorFilter` — so a per-app override can disable an
272-
// individual rectification behavior and survive sanitizeTaskMetadata.
269+
// per-app action toggles for the `branch-reconcile` task type; `autoClose` is
270+
// the `issue-reconcile` toggle (ON unless explicitly false — OFF forbids the
271+
// coordinator from closing an issue or filing a follow-up, leaving it to only
272+
// comment + release the claim). Each lives in the shared task-metadata
273+
// allowlist — like `prAuthorFilter` / `issueAuthorFilter` — so a per-app
274+
// override can disable an individual rectification behavior and survive
275+
// sanitizeTaskMetadata.
273276
const ALLOWED_TASK_METADATA_KEYS = [
274277
...PIPELINE_BEHAVIOR_FLAGS, 'readOnly',
275-
'cleanupMerged', 'openPr', 'resolveConflicts', 'autoMerge'
278+
'cleanupMerged', 'openPr', 'resolveConflicts', 'autoMerge', 'autoClose'
276279
];
277280

278281
// pr-watcher author-gate values. 'self' = PRs opened by the gh-authenticated

server/services/cosTaskGenerator.js

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,11 +1582,13 @@ export async function generateManagedAppImprovementTaskForType(taskType, app, st
15821582
// tick retries instead of waiting out a full recheck cadence.
15831583
// The detector keys on the RESOLVED promptTaskType so a claim-work router run
15841584
// probes the concrete tracker (claim-issue → GitHub issues, plan-task → PLAN.md).
1585-
// branch-reconcile is PERPETUAL but is NOT gated by the generic work-detector
1586-
// registry — its "detector" and its actual work are the SAME scan (the
1587-
// deterministic reconcile below), so splitting them would double the git/gh
1588-
// I/O. Its dedicated block further down does the park/clear itself.
1589-
if (interval.type === taskSchedule.INTERVAL_TYPES.PERPETUAL && taskType !== 'branch-reconcile') {
1585+
// branch-reconcile and issue-reconcile are PERPETUAL but are NOT gated by the
1586+
// generic work-detector registry — for each, the "detector" and the actual
1587+
// work are the SAME scan (the deterministic reconcile below), so splitting them
1588+
// would double the git/gh I/O. Their dedicated blocks further down do the
1589+
// park/clear themselves.
1590+
if (interval.type === taskSchedule.INTERVAL_TYPES.PERPETUAL
1591+
&& taskType !== 'branch-reconcile' && taskType !== 'issue-reconcile') {
15901592
const { detectActionableWork } = await import('./perpetualWork.js');
15911593
const detection = await detectActionableWork(promptTaskType, app, {
15921594
issueAuthorFilter: metadata.issueAuthorFilter || 'self'
@@ -1682,6 +1684,59 @@ export async function generateManagedAppImprovementTaskForType(taskType, app, st
16821684
emitLog('info', `🔀 branch-reconcile dispatching for ${app.name}: ${actionable.length} in-flight branch(es)`, { appId: app.id, analysisType: taskType });
16831685
}
16841686

1687+
// issue-reconcile: deterministic pre-step — scan the app's GitHub repo for
1688+
// ZOMBIE issues (open + `in-progress` yet with their PR merged and no live
1689+
// claim anywhere) and hand the set to the coordinator agent. Same perpetual
1690+
// drain shape as branch-reconcile: the scan IS the work-detector (gh/git only,
1691+
// no LLM), and the coordinator dispatches only while zombies remain — else it
1692+
// PARKS on the daily recheckCron. `{zombieIssues}` carries the set into the
1693+
// prompt. No worktree: the coordinator mutates issue state over `gh` only.
1694+
let zombieIssuesBlock = '';
1695+
if (taskType === 'issue-reconcile') {
1696+
const { reconcile, zombieSignature, formatZombiesForPrompt } = await import('./issueReconcile.js');
1697+
// The live-claim guard (open PR + local/remote/CoS claim branch, scanned via
1698+
// for-each-ref inside reconcile) already covers every agent past Phase 2 of
1699+
// the claim flow — the point at which it creates its claim/worktree branch.
1700+
// The sub-minute pre-branch window is left to the coordinator's per-zombie
1701+
// re-verification, so no separate active-agent set is threaded here.
1702+
const autoClose = metadata.autoClose !== false;
1703+
const result = await reconcile(app.repoPath).catch((err) => {
1704+
emitLog('warn', `issue-reconcile pre-step failed for ${app.name}: ${err.message}`, { appId: app.id });
1705+
return null;
1706+
});
1707+
// null = non-GitHub remote OR transient gh failure → skip WITHOUT parking so
1708+
// the next tick retries (gh/git-only cost), mirroring branch-reconcile.
1709+
if (!result) return null;
1710+
if (result.stalled.length) {
1711+
// In-progress issues with NO merged PR and NO live claim — a different stuck
1712+
// state (claimed, nothing shipped) that issue-reconcile deliberately does
1713+
// NOT auto-heal. Surface them so they're not silently ignored.
1714+
emitLog('info', `🧟 issue-reconcile ${app.name}: ${result.stalled.length} stalled in-progress issue(s) with no merged PR (left for human/branch-reconcile)`, { appId: app.id, analysisType: taskType });
1715+
}
1716+
if (result.zombies.length === 0) {
1717+
await taskSchedule.parkPerpetual(taskType, app.id, { reason: 'no-zombie-issues', actionableCount: 0, signature: null });
1718+
emitLog('info', `🧟 issue-reconcile parked for ${app.name}: no zombie issues`, { appId: app.id });
1719+
return null;
1720+
}
1721+
// Convergence guard — identical to branch-reconcile's: a productive run closes
1722+
// or releases zombies (changing the signature) → keep draining; an unchanged
1723+
// signature (coordinator errored, or a case it punted to a human) → park on
1724+
// the recheck cadence and CLEAR the signature so the next daily recheck
1725+
// re-drives unconditionally rather than re-parking forever.
1726+
const signature = zombieSignature(result.zombies);
1727+
const lastSignature = await taskSchedule.getPerpetualSignature(taskType, app.id);
1728+
if (signature === lastSignature) {
1729+
await taskSchedule.parkPerpetual(taskType, app.id, { reason: 'no-progress', actionableCount: result.zombies.length, signature: null });
1730+
emitLog('info', `🧟 issue-reconcile parked for ${app.name}: ${result.zombies.length} zombie issue(s) unchanged since last run (no progress — will re-drive on next recheck)`, { appId: app.id });
1731+
return null;
1732+
}
1733+
await taskSchedule.clearPerpetualPark(taskType, app.id);
1734+
await taskSchedule.setPerpetualSignature(taskType, app.id, signature);
1735+
metadata.perpetual = true;
1736+
zombieIssuesBlock = formatZombiesForPrompt(result.zombies, { fullName: result.fullName, autoClose });
1737+
emitLog('info', `🧟 issue-reconcile dispatching for ${app.name}: ${result.zombies.length} zombie issue(s)`, { appId: app.id, analysisType: taskType });
1738+
}
1739+
16851740
// Honor a direct claim-work prompt customization if the user set one;
16861741
// otherwise delegate to the resolved tracker's prompt body via
16871742
// getTaskPrompt(promptTaskType), which reads THAT type's interval.prompt
@@ -1846,6 +1901,7 @@ export async function generateManagedAppImprovementTaskForType(taskType, app, st
18461901
.replace(/\{referenceData\}/g, () => referenceDataBlock)
18471902
.replace(/\{prData\}/g, () => prDataBlock)
18481903
.replace(/\{inFlightBranches\}/g, () => inFlightBranchesBlock)
1904+
.replace(/\{zombieIssues\}/g, () => zombieIssuesBlock)
18491905
.replace(/\{repoFullName\}/g, () => prRepoFullName)
18501906
.replace(/\{defaultBranch\}/g, () => prDefaultBranch)
18511907
.replace(/\{planConstraint\}/g, () => planConstraintBlock);

0 commit comments

Comments
 (0)