Skip to content

TaskShellProgress.recentOutput is a required string, so the runtime substitutes the display literal (no output yet) when a shell task has produced nothing #4675

Description

@nonemec-havok

Describe the bug

TaskShellProgress.recentOutput is declared required and non-nullable, so
there is no legal way for the runtime to answer "shell task, running, nothing
emitted yet."
It resolves the conflict by substituting a human-readable
placeholder into the data field:

// sdk/index.js @ 974738, identical copy in app.js @ 2324928
async getBackgroundTaskProgress(e) {
  if (e.type === "shell") {
    let r = this.getSessionShellContext()?.getShellTaskProgress(e.id);
    return { type: "shell", recentOutput: r?.recentOutput ?? "(no output yet)", pid: r?.pid };
  }
  ...
}

The contrast with the neighbouring field is the whole bug: pid is optional,
so when it is unknown it is simply absent — correct and unambiguous. In the
same response recentOutput cannot be absent, so absence is rendered as
content.

This is not a cosmetic default. A programmatic client cannot distinguish:

  1. the task has produced no output yet, from
  2. the task printed the characters (no output yet).

Both arrive as the same bytes in the same required field. The API states
something false about the process, and the falsehood is undetectable at the
call site.

null does not help: the dispatcher reserves it for an unlisted id, so a
listed-but-silent task always reaches the sentinel branch —

// app.js @ 2213353
case "tasks_get_progress": {
  let o = this.getBackgroundTasks().find(s => s.id === wo(n, "taskId", "id"));
  return { progress: o ? await this.getBackgroundTaskProgress(o) : null }
}

Affected version

@github/copilot 1.0.80 (@github/copilot-sdk 1.0.11), win32 x64.

Current generated contract, copilot-sdk/dist/generated/rpc.d.ts:

/** @experimental */
export interface TaskShellProgress {
    type: "shell";
    recentOutput: string;   // required — cannot express "nothing yet"
    pid?: number;           // optional — correctly absent when unknown
}

Steps to reproduce the behavior

Poll tasks.getProgress against a shell task that stays silent after start —
the window is easy to miss with a task that prints immediately, so force it:

  1. Start an attached (sync) shell task whose command emits nothing for
    ~20s, then prints steadily:

    node -e "setTimeout(()=>{let i=0;const t=setInterval(()=>{console.log(`line ${++i}`);if(i>60)clearInterval(t)},300)},20000)"
    
  2. Poll tasks.getProgress({ id }) at ~250 ms from the moment the task is
    listed.

  3. Observe the sentinel for the whole pre-first-output window. Verbatim,
    17 consecutive polls over ~4.1 s (att/exec from tasks.list):

    {"ms":78782,"id":"node-task","status":"running","att":"attached","exec":"sync","recentOutput":"(no output yet)"}
    {"ms":82894,"id":"node-task","status":"running","att":"attached","exec":"sync","pid":28012,"recentOutput":"(no output yet)"}

    Note pid behaving correctly across those same polls — absent at 78782,
    present at 82894 — while recentOutput cannot be.

  4. Once the command starts printing, recentOutput carries real output
    normally.

Expected behavior

recentOutput should be able to express absence, so a client can render it
honestly. Either:

  • make it optional (recentOutput?: string) and omit it — consistent with
    how pid already handles the unknown case, and the preferred shape; or
  • keep it required and return the empty string, reserving all non-empty
    values for real process output.

Either way the invariant worth stating explicitly in the schema is: every byte
in recentOutput was written by the task.
Any client-side presentation of
"nothing yet" belongs to the client.

Additional context

Why a placeholder is worse than absence here. Downstream this does not stay
cosmetic. In our host the value is non-empty, so it passes a truthiness guard
and is accumulated into the per-task buffer used to content-match a task to its
largeOutput file. No real log contains (no output yet), so the match can
never succeed and the task is permanently locked out of its own complete log —
a silent, unrecoverable failure caused entirely by a field that was supposed to
mean "no data". An empty string, or an absent field, would have been handled
correctly by code that already existed.

Relationship to #4630 — related but neither subsumes the other. #4630 asks
for largeOutputFilePath / largeOutputTotalBytes on this same interface,
because recentOutput is a lossy window. That is a fidelity gap in real
output; this is a correctness bug where the field's contents are not output at
all. They also do not overlap in time: #4630 notes the largeOutput file
appears only after a size threshold (~17s in its probe), which is precisely the
window in which this sentinel is served — so landing #4630 would leave this
unfixed, and fixing this leaves #4630's lossiness untouched. Separate fixes:
this one is a nullability change to an existing field, #4630 is a schema
addition currently blocked by "additionalProperties": false.

Scope. Observed on an attached (sync) task. The producing branch is not
conditioned on attachment, so detached tasks should behave the same, but I only
measured the attached case. The pre-first-output window is the trigger; a task
that prints immediately closes it too fast to notice, which is likely why this
has gone unreported.

Correction to an earlier reading. An attached task is not permanently
without a progress record — in the same session a different attached task
returned real recentOutput throughout. The sentinel is a startup-window state,
not an attachment-mode one.

  • Discovered while polling from a GUI host embedding the SDK in-process.
  • Evidence read from the shipped bundle at the offsets cited above, not inferred
    from behaviour alone.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions