fix(coding-agent): prevented duplicate rows in task execution scrollback

- Updated task tool execution to pin live regions and drop partial snapshots once rows commit.
- Tracked background task frozen styled rows and render timestamps to prevent clock drift on committed history.
- Added tests verifying detached and blocking task progress do not duplicate rows in scrollback.
This commit is contained in:
can1357
2026-07-30 04:01:06 +02:00
parent 09545697ee
commit 2905a23fc1
4 changed files with 366 additions and 35 deletions
+1
View File
@@ -22,6 +22,7 @@
### Fixed
- Fixed task tool blocks duplicating their per-agent progress rows into terminal scrollback on every update: live task frames now pin the transcript live region so mid-run rows are never recorded as frozen snapshots, and a detached background task freezes its progress the moment any of its rows commit to scrollback instead of mutating committed history.
- Fixed Codex reset fireworks comparing different quota tiers or plans, preventing false celebrations when usage reports switch between Spark and base weekly limits.
- Fixed Cursor ranged-read results losing the full file byte size after applying the requested window.
- Fixed empty Codex final-stop recovery discarding an earlier commentary message when both messages shared response metadata.
@@ -203,6 +203,13 @@ class SafeToolRendererComponent implements Component {
*/
export interface TranscriptLiveRegionProbe {
isBlockInLiveRegion(component: Component): boolean;
/**
* Whether none of the block's rows have entered native scrollback (see
* `TranscriptContainer.isBlockUncommitted`). Optional: standalone hosts
* without commit tracking omit it, and blocks treat their rows as
* uncommitted.
*/
isBlockUncommitted?(component: Component): boolean;
}
/** Minimal TUI surface ToolExecutionComponent uses to schedule repaints and share image budget. */
@@ -337,10 +344,21 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
// transcript, e.g. in tests): whether this block is still repaintable.
#liveRegion?: TranscriptLiveRegionProbe;
// One-way latch for a detached (`async.state === "running"`) task block
// that left the transcript live region: its rows are commit-eligible
// history, so progress renders static gray and further partial snapshots are
// dropped (see #maybeFreezeBackgroundTask).
// whose rows became native-scrollback history — it left the transcript
// live region, or its head rows were committed while it was still the
// live tail. Further partial snapshots are dropped so committed rows are
// never mutated (see #maybeFreezeBackgroundTask).
#backgroundTaskFrozen = false;
// Whether the freeze may restyle the progress rows static gray. Set only
// when the latch fired while no row was committed: a recolor of rows
// already on the tape would itself diverge immutable history and force an
// erase-replay (or, with scrollback rebuild off, a duplicate slab).
#backgroundTaskFrozenStyled = false;
// Wall clock captured at each repaintable rebuild of a task card and
// reused verbatim once the card freezes or any of its rows commit, so
// time-derived rows (current-tool elapsed, retry countdown) cannot drift
// a committed byte on later rebuilds (theme epoch, image toggles).
#taskRenderNowMs = Date.now();
// Set on each `render()` when the last painted pending shape must be
// replayed wholesale when the first result arrives. Reset gates key off
// these so a topology-changing update that lands before the shape reaches
@@ -559,13 +577,17 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
_toolCallId?: string,
): void {
// A detached task spawn keeps streaming progress snapshots after the
// block froze (left the transcript live region). Drop them: the rows are
// static gray history now, and repainting would rewrite rows the engine
// may already have committed to native scrollback. The terminal snapshot
// (async completed/failed → isPartial=false) still applies so a block
// that is still on screen settles on real results.
if (isPartial && this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) {
return;
// block froze (left the transcript live region, or its rows entered
// native scrollback). Drop them: repainting would rewrite rows the
// engine may already have committed. The terminal snapshot (async
// completed/failed → isPartial=false) still settles a card that is
// wholly uncommitted (still on screen); once any row is on the tape
// the card is immutable history — replacing it would re-commit the
// whole slab below the stale copy — so the settlement is dropped and
// the job's result surfaces through its own delivery message.
if (this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) {
if (isPartial) return;
if (!(this.#liveRegion?.isBlockUncommitted?.(this) ?? true)) return;
}
const hadNoResult = this.#result === undefined;
const wasPartialResult = this.#result !== undefined && this.#isPartial;
@@ -713,22 +735,30 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
}
/**
* Freeze a detached (`async.state === "running"`) task block once it leaves
* the transcript's live region. Past that seam its rows are commit-eligible
* native-scrollback history: repaint the progress rows static gray and drop
* further partial snapshots. One-way — blocks never re-enter the live
* region. Returns whether the block is frozen.
* Freeze a detached (`async.state === "running"`) task block once its rows
* become native-scrollback history: the block left the transcript's live
* region (a later block streams below it), or — while it is still the
* live tail — its head rows were committed because the frame outgrew the
* viewport. Committed rows are immutable, so from that point every further
* partial snapshot is dropped. Rows restyle static gray only when nothing
* is committed yet; otherwise the bytes stay exactly as painted. One-way —
* blocks never re-enter the live region. Returns whether the block is
* frozen.
*/
#maybeFreezeBackgroundTask(): boolean {
if (this.#backgroundTaskFrozen) return true;
if (this.#toolName !== "task" || this.#liveRegion === undefined) return false;
const asyncState = (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state;
if (asyncState !== "running") return false;
if (this.#liveRegion.isBlockInLiveRegion(this)) return false;
const uncommitted = this.#liveRegion.isBlockUncommitted?.(this) ?? true;
if (uncommitted && this.#liveRegion.isBlockInLiveRegion(this)) return false;
this.#backgroundTaskFrozen = true;
this.#updateSpinnerAnimation();
this.#updateDisplay();
this.#ui.requestRender();
if (uncommitted) {
this.#backgroundTaskFrozenStyled = true;
this.#updateDisplay();
this.#ui.requestRender();
}
return true;
}
@@ -784,16 +814,16 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
/**
* Keeps in-flight TV-wall frames out of immutable native scrollback: the
* `vibe_wait` wall and displaceable snapshots (`hub` waiting polls, `todo`
* lists). Their frames replace each other rather than append, and their
* rows mutate every spinner tick — an unpinned commit records a per-tick
* frozen snapshot AND force-seals the block (see TranscriptContainer's
* committed-snapshot seal), so the next poll stacks a new frame instead of
* displacing this one.
* `vibe_wait` wall, displaceable snapshots (`hub` waiting polls, `todo`
* lists), and live `task` calls. Their frames replace each other rather
* than append — task progress rows rewrite in place on every snapshot —
* so an unpinned commit records a per-tick frozen snapshot (and for
* displaceable blocks force-seals them, stacking the next poll below).
* The finalized frame commits exactly once when the pin lifts.
*/
isNativeScrollbackLiveRegionPinned(): boolean {
if (this.isTranscriptBlockFinalized()) return false;
return this.#toolName === "vibe_wait" || this.#displaceableByToolName !== undefined;
return this.#toolName === "vibe_wait" || this.#toolName === "task" || this.#displaceableByToolName !== undefined;
}
/**
@@ -827,8 +857,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
this.#sealed = true;
this.#displaceableByToolName = undefined;
// A sealed detached task is abandoned history: settle its progress rows
// on static gray.
// on static gray — but only while none of them are committed; a recolor
// on the tape would diverge immutable history.
this.#backgroundTaskFrozen = true;
if (this.#liveRegion?.isBlockUncommitted?.(this) ?? true) {
this.#backgroundTaskFrozenStyled = true;
}
this.stopAnimation();
this.#updateDisplay();
this.#ui.requestRender();
@@ -888,7 +922,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
// TUI startup, so a result rendered before it lands must re-shape once it
// does (it gates Image children vs text fallback in #rebuildDisplay); keyed
// here for the same reason markdown.ts keys its render cache on it.
const key = `${this.#resultVersion}|${this.#expanded}|${this.#isPartial}|${this.#spinnerFrame ?? "-"}|${this.#showImages}|${getThemeEpoch()}|${this.#displayInputVersion}|${this.#backgroundTaskFrozen}|${TERMINAL.imageProtocol ?? "-"}|${this.#imageSizeKey()}`;
const key = `${this.#resultVersion}|${this.#expanded}|${this.#isPartial}|${this.#spinnerFrame ?? "-"}|${this.#showImages}|${getThemeEpoch()}|${this.#displayInputVersion}|${this.#backgroundTaskFrozenStyled}|${TERMINAL.imageProtocol ?? "-"}|${this.#imageSizeKey()}`;
if (key === this.#lastDisplayKey && this.#displayBuilt) return;
this.#lastDisplayKey = key;
@@ -1292,9 +1326,17 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
// draws every dispatched agent as a progress/result line, so tell
// `renderCall` to drop its duplicate streaming preview list.
context.hasResult = Boolean(this.#result);
// Out of the transcript live region: progress rows render static gray
// (see task/render.ts).
context.frozen = this.#backgroundTaskFrozen;
// Settled as history (out of the live region, before any row entered
// the tape): progress rows render static gray (see task/render.ts).
context.frozen = this.#backgroundTaskFrozenStyled;
// Freeze the render clock alongside the latch — and independently the
// moment any row commits, closing the window between a commit paint
// and the next snapshot where a settings-triggered rebuild could
// re-derive elapsed/countdown bytes under committed rows.
if (!this.#backgroundTaskFrozen && (this.#liveRegion?.isBlockUncommitted?.(this) ?? true)) {
this.#taskRenderNowMs = Date.now();
}
context.nowMs = this.#taskRenderNowMs;
} else if (isEditLikeToolName(this.#toolName)) {
context.editMode = this.#editMode;
const previews = this.#editDiffPreview;
+20 -4
View File
@@ -49,6 +49,13 @@ interface TaskRenderContext {
* commit-eligible rows do not repaint after entering native scrollback.
*/
frozen?: boolean;
/**
* Wall clock for time-derived rows (current-tool elapsed, retry countdown).
* The component freezes it once the block settles or any of its rows enter
* native scrollback, so identical-input rebuilds stay byte-identical with
* committed history. Absent: render with the live clock.
*/
nowMs?: number;
}
type TaskRenderOptions = RenderResultOptions & { renderContext?: TaskRenderContext };
@@ -888,6 +895,7 @@ function renderAgentProgress(
frozen = false,
seenNestedTasks?: WeakSet<object>,
nestedDepth = 0,
nowMs = Date.now(),
): string[] {
const lines: string[] = [];
@@ -964,7 +972,7 @@ function renderAgentProgress(
toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
}
if (progress.currentToolStartMs) {
const elapsed = Date.now() - progress.currentToolStartMs;
const elapsed = nowMs - progress.currentToolStartMs;
if (elapsed > 5000) {
toolLine += `${theme.sep.dot}${theme.fg("warning", formatDuration(elapsed))}`;
}
@@ -986,7 +994,7 @@ function renderAgentProgress(
// long until the next attempt. Without this, the parent UI would just
// keep spinning while a child sleeps on a 3-hour provider rate-limit.
if (progress.retryState && progress.status === "running") {
const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - nowMs);
const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
const summary =
`retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
@@ -1079,6 +1087,7 @@ function renderAgentProgress(
frozen,
seenNestedTasks,
nestedDepth,
nowMs,
);
for (const line of nestedLines) {
lines.push(`${continuePrefix}${line}`);
@@ -1560,6 +1569,7 @@ export function renderResult(
return framedBlock(theme, width => {
const { expanded, isPartial, spinnerFrame } = options;
const frozen = options.renderContext?.frozen === true;
const nowMs = options.renderContext?.nowMs ?? Date.now();
const lines: string[] = [];
// Result rows win once any exist; progress rows for spawns without a
@@ -1577,7 +1587,9 @@ export function renderResult(
lines.push(formatHiddenProgressLine(ordered.slice(0, ordered.length - visible.length), theme));
}
for (const progress of visible) {
lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen));
lines.push(
...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs),
);
}
} else if (details.results && details.results.length > 0) {
const ordered = orderResultsForDisplay(details.results);
@@ -1602,7 +1614,9 @@ export function renderResult(
)
: [];
for (const progress of supplementalProgress) {
lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen));
lines.push(
...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs),
);
}
const summaryParts: string[] = [];
@@ -1742,6 +1756,7 @@ function renderNestedTaskTree(
frozen = false,
seen: WeakSet<object> = new WeakSet<object>(),
depth = 0,
nowMs = Date.now(),
): string[] {
const lines: string[] = [];
for (const details of detailsList) {
@@ -1788,6 +1803,7 @@ function renderNestedTaskTree(
frozen,
seen,
depth + 1,
nowMs,
),
);
});
@@ -1,9 +1,11 @@
import { afterEach, beforeAll, describe, expect, test, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { theme as activeTheme, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { AgentProgress, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types";
import { evalToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/eval-render";
import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils";
import { type Component, TUI } from "@oh-my-pi/pi-tui";
@@ -159,10 +161,62 @@ function makeEvalProbeResult(output: string, status: "running" | "complete") {
isError: false,
};
}
/**
* Six running designer agents at `tick`, shaped as a detached (async-running)
* task snapshot. Rows carry the time-derived fields the real executor sets —
* a current-tool start (elapsed suffix) and one retry countdown — so the
* regression also covers clock-derived byte drift.
*/
function detachedTaskDetails(tick: number): TaskToolDetails {
const now = Date.now();
const progress: AgentProgress[] = Array.from({ length: 6 }, (_, index) => ({
index,
id: `Task${index + 1}`,
agent: "designer",
agentSource: "bundled",
status: "running",
task: `Design concept ${index + 1}`,
assignment: `Design concept ${index + 1}`,
description: `Concept ${index + 1}, update ${tick}`,
currentTool: tick % 2 === 0 ? "read" : "hub",
currentToolStartMs: now - 20_000,
lastIntent: `progress ${tick}`,
recentTools: [],
recentOutput: [],
toolCount: tick,
requests: tick,
tokens: tick * 100,
cost: tick / 100,
durationMs: tick * 100,
retryState:
index === 5
? {
attempt: 1,
maxAttempts: 5,
delayMs: 120_000,
errorMessage: "429 rate limited",
startedAtMs: now,
}
: undefined,
}));
return {
projectAgentsDir: null,
results: [],
totalDurationMs: tick * 100,
progress,
async: { state: "running", jobId: "Task1", type: "task" },
};
}
describe("streaming tool output never sprays duplicate scrollback banners", () => {
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
await initTheme();
});
afterAll(() => {
resetSettingsForTest();
});
afterEach(() => {
if (ORIGINAL_ROWS) Object.defineProperty(process.stdout, "rows", ORIGINAL_ROWS);
else Reflect.deleteProperty(process.stdout, "rows");
@@ -206,6 +260,224 @@ describe("streaming tool output never sprays duplicate scrollback banners", () =
}
}, 30_000);
// The detached-task smear (user report): a background (`async.state ===
// "running"`) task block reports finalized so later turns can commit, but
// as the transcript tail its head rows enter native scrollback the moment
// the frame outgrows the viewport. Progress snapshots kept mutating those
// committed rows; with scrollback rebuild off (default) every audit repair
// appended a fresh copy of the agent one-liners below the stale one.
test("task: detached progress never duplicates agent rows in scrollback", async () => {
const rows = 24;
stubStdoutRows(rows);
const term = new VirtualTerminal(100, rows);
const scheduler = makeDrainableScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
// Default engine mode: divergence repairs append below the stale copy,
// which is exactly the duplication this test guards against.
tui.setScrollbackRebuild(false);
const transcript = new TranscriptContainer();
const taskArgs = {
context: Array.from({ length: 40 }, (_, index) => `context-line-${index}`).join("\n"),
tasks: Array.from({ length: 6 }, (_, index) => ({
name: `Task${index + 1}`,
agent: "designer",
task: `Design concept ${index + 1}`,
})),
};
const task = new ToolExecutionComponent(
"task",
taskArgs,
{ liveRegion: transcript },
undefined,
tui,
process.cwd(),
);
transcript.addChild(task);
tui.addChild(transcript);
tui.addChild(new Footer(6));
try {
tui.start();
scheduler.flush();
await term.flush();
for (let tick = 1; tick <= 12; tick++) {
task.updateResult(
{ content: [{ type: "text", text: "running" }], details: detachedTaskDetails(tick) },
true,
);
term.scrollLines(1000);
tui.requestRender();
scheduler.flush();
await term.flush();
}
// The turn's reply streams in below the card (the screenshot shape):
// the whole card — progress rows included — is pushed above the
// window top and committed to the tape as frozen history.
transcript.addChild(new StaticBlock(Array.from({ length: 24 }, (_, i) => `reply-line-${i}`)));
term.scrollLines(1000);
tui.requestRender();
scheduler.flush();
await term.flush();
// A rebuild without a new snapshot (settings toggle, theme epoch)
// re-derives the frame; time-derived rows (current-tool elapsed,
// retry countdown) must not drift under committed history, so the
// render clock freezes with the latch: the rebuild output must be
// byte-identical even with the wall clock 90s ahead.
const beforeRebuild = task.render(100).join("\n");
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 90_000);
task.setShowImages(false);
expect(task.render(100).join("\n")).toBe(beforeRebuild);
term.scrollLines(1000);
tui.requestRender();
scheduler.flush();
await term.flush();
// The terminal snapshot settles an on-screen card in place, but a
// card with committed rows is immutable history: replacing it would
// re-commit the whole slab below the stale copy, so it is dropped
// (the job result surfaces through its own delivery message).
const settledDetails = detachedTaskDetails(13);
settledDetails.async = { state: "completed", jobId: "Task1", type: "task" };
settledDetails.progress = [];
settledDetails.results = Array.from({ length: 6 }, (_, index) => ({
index,
id: `Task${index + 1}`,
agent: "designer",
agentSource: "bundled" as const,
task: `Design concept ${index + 1}`,
assignment: `Design concept ${index + 1}`,
description: `Concept ${index + 1}`,
exitCode: 0,
output: `concept ${index + 1} shipped`,
stderr: "",
truncated: false,
durationMs: 1000 + index,
tokens: 1200,
requests: 12,
}));
task.updateResult({ content: [{ type: "text", text: "done" }], details: settledDetails }, false);
// A committed card is immutable: the dropped settlement leaves its
// render byte-identical (the frozen progress rows, not result rows).
expect(task.render(100).join("\n")).toBe(beforeRebuild);
for (let i = 0; i < 2; i++) {
term.scrollLines(1000);
tui.requestRender();
scheduler.flush();
await term.flush();
}
const buffer = plainScrollBuffer(term);
// The launch brief is append-only content: it belongs in history,
// exactly once.
expect(buffer.filter(line => line.includes("context-line-0"))).toHaveLength(1);
// Collapsed view shows the last 4 agents; each one-liner must appear
// exactly once across history + grid — no stale generations stacked.
for (let index = 3; index <= 6; index++) {
expect(buffer.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1);
}
expect(buffer.filter(line => line.includes("more agents"))).toHaveLength(1);
expect(buffer.filter(line => line.includes("retrying 1/5")).length).toBeLessThanOrEqual(1);
// The dropped settlement never re-rendered the card as results.
expect(buffer.some(line => line.includes("shipped"))).toBe(false);
} finally {
task.stopAnimation();
tui.stop();
await term.flush();
}
}, 30_000);
// A blocking (foreground) task call is a self-replacing dashboard: its
// progress rows rewrite in place, so none of them may be recorded as
// frozen scrollback snapshots mid-run. Only the finalized result commits.
test("task: blocking progress taller than the viewport records only the final frame", async () => {
const rows = 24;
stubStdoutRows(rows);
const term = new VirtualTerminal(100, rows);
const scheduler = makeDrainableScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
tui.setScrollbackRebuild(false);
const transcript = new TranscriptContainer();
const taskArgs = {
context: Array.from({ length: 40 }, (_, index) => `context-line-${index}`).join("\n"),
tasks: Array.from({ length: 6 }, (_, index) => ({
name: `Task${index + 1}`,
agent: "designer",
task: `Design concept ${index + 1}`,
})),
};
const task = new ToolExecutionComponent(
"task",
taskArgs,
{ liveRegion: transcript },
undefined,
tui,
process.cwd(),
);
transcript.addChild(task);
tui.addChild(transcript);
tui.addChild(new Footer(6));
try {
tui.start();
scheduler.flush();
await term.flush();
for (let tick = 1; tick <= 12; tick++) {
const details = detachedTaskDetails(tick);
delete details.async; // blocking call: no async job attached
task.updateResult({ content: [{ type: "text", text: "running" }], details }, true);
term.scrollLines(1000);
tui.requestRender();
scheduler.flush();
await term.flush();
}
const midRun = plainScrollBuffer(term);
for (let index = 1; index <= 6; index++) {
expect(midRun.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1);
}
// Finalize: results replace the progress rows and commit exactly once.
const finalDetails = detachedTaskDetails(12);
delete finalDetails.async;
finalDetails.progress = [];
finalDetails.results = Array.from({ length: 6 }, (_, index) => ({
index,
id: `Task${index + 1}`,
agent: "designer",
agentSource: "bundled" as const,
task: `Design concept ${index + 1}`,
assignment: `Design concept ${index + 1}`,
description: `Concept ${index + 1}`,
exitCode: 0,
output: `concept ${index + 1} shipped`,
stderr: "",
truncated: false,
durationMs: 1000 + index,
tokens: 1200,
requests: 12,
}));
task.updateResult({ content: [{ type: "text", text: "done" }], details: finalDetails }, false);
for (let i = 0; i < 2; i++) {
term.scrollLines(1000);
tui.requestRender();
scheduler.flush();
await term.flush();
}
const settled = plainScrollBuffer(term);
expect(settled.filter(line => line.includes("context-line-0"))).toHaveLength(1);
for (let index = 1; index <= 6; index++) {
expect(settled.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1);
}
} finally {
task.stopAnimation();
tui.stop();
await term.flush();
}
}, 30_000);
test("eval: collapsed cell output stays within the viewport budget", () => {
const rows = 18;
stubStdoutRows(rows);